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.
Files changed (47) hide show
  1. package/dist/bin.mjs +653 -229
  2. package/dist/bin.mjs.map +1 -1
  3. package/dist/cli.mjs +644 -232
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/{config-BJz1m9eN.mjs → config-1ouUTKQr.mjs} +15 -4
  6. package/dist/config-1ouUTKQr.mjs.map +1 -0
  7. package/dist/{config-DlCUMyCG.mjs → config-YRRW9l89.mjs} +1 -1
  8. package/dist/docs/SKILL.md +8 -1
  9. package/dist/docs/guidelines/bun-monorepo-patterns.md +65 -66
  10. package/dist/docs/guidelines/cli-agent-skill-patterns.md +415 -169
  11. package/dist/docs/guidelines/common-doc-guidelines.md +2 -2
  12. package/dist/docs/guidelines/convex-limits-best-practices.md +39 -39
  13. package/dist/docs/guidelines/convex-rules.md +13 -13
  14. package/dist/docs/guidelines/electron-app-development-patterns.md +18 -18
  15. package/dist/docs/guidelines/error-handling-rules.md +3 -0
  16. package/dist/docs/guidelines/general-coding-rules.md +2 -1
  17. package/dist/docs/guidelines/general-comment-rules.md +3 -2
  18. package/dist/docs/guidelines/general-eng-agent-principles.md +126 -0
  19. package/dist/docs/guidelines/general-tdd-guidelines.md +10 -4
  20. package/dist/docs/guidelines/general-testing-rules.md +4 -0
  21. package/dist/docs/guidelines/golden-testing-guidelines.md +9 -9
  22. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +49 -49
  23. package/dist/docs/guidelines/python-cli-patterns.md +5 -1
  24. package/dist/docs/guidelines/python-modern-guidelines.md +7 -4
  25. package/dist/docs/guidelines/python-rules.md +6 -0
  26. package/dist/docs/guidelines/release-notes-guidelines.md +18 -2
  27. package/dist/docs/guidelines/supply-chain-hardening.md +84 -29
  28. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +27 -5
  29. package/dist/docs/guidelines/typescript-cli-tool-rules.md +18 -18
  30. package/dist/docs/guidelines/typescript-code-coverage.md +8 -6
  31. package/dist/docs/guidelines/typescript-rules.md +9 -11
  32. package/dist/docs/guidelines/typescript-sorting-patterns.md +1 -1
  33. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +6 -6
  34. package/dist/docs/shortcuts/standard/new-shortcut.md +14 -0
  35. package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -1
  36. package/dist/docs/shortcuts/system/shortcut-explanation.md +16 -1
  37. package/dist/docs/shortcuts/system/skill-baseline.md +8 -1
  38. package/dist/docs/tbd-design.md +43 -43
  39. package/dist/docs/tbd-docs.md +1 -1
  40. package/dist/docs/tbd-prime.md +3 -3
  41. package/dist/index.mjs +1 -1
  42. package/dist/{src-CtZIHxYM.mjs → src-DTyyuaG_.mjs} +2 -2
  43. package/dist/{src-CtZIHxYM.mjs.map → src-DTyyuaG_.mjs.map} +1 -1
  44. package/dist/tbd +653 -229
  45. package/package.json +1 -1
  46. package/dist/config-BJz1m9eN.mjs.map +0 -1
  47. 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-CtZIHxYM.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-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 resolveDataSyncDir, I as resolveSharedTbdPaths, M as getWorkspaceDir, N as isValidWorkspaceName, O as TBD_SHORTCUTS_SYSTEM, P as resolveAtticDir, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-BJz1m9eN.mjs";
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 createRequire(import.meta.url)("../../../package.json").version;
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: "lww",
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
- const conflicts = await onMergeNeeded();
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`. Aborts if the
2071
- * data-sync worktree is dirty or has a merge in progress.
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 ((await git("-C", worktreePath, "status", "--porcelain")).trim()) throw new Error("Refusing to rescue: the tbd-sync worktree has uncommitted changes. Commit or stash them, then retry.");
2076
- if (await git("-C", worktreePath, "rev-parse", "-q", "--verify", "MERGE_HEAD").then(() => true).catch(() => false)) throw new Error("Refusing to rescue: a merge is in progress in the tbd-sync worktree. Resolve or abort it, then retry.");
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
- await mkdir(paths.sharedLocksDir, { recursive: true });
2349
- return withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
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) => 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}`, "--limit=10");
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 ${ahead} commit(s) to ${remote}/${syncBranch}`);
5675
- else if (result.conflicts && result.conflicts.length > 0) this.output.warn(`Push completed with ${result.conflicts.length} conflict(s) (see attic for details)`);
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
- async doPushWithRetry(syncBranch, remote) {
5684
- return pushWithRetry(syncBranch, remote, async () => {
5685
- const conflicts = [];
5686
- const localIssues = await listIssues(this.dataSyncDir);
5687
- for (const localIssue of localIssues) try {
5688
- if (await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/issues/${localIssue.id}.md`)) {
5689
- const result = mergeIssues(null, localIssue, await readIssue(this.dataSyncDir, localIssue.id));
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(`Issue ${localIssue.id} not on remote, no merge needed`);
5931
+ this.output.debug("No merge commit needed (conflicts already resolved)");
5695
5932
  }
5696
- return conflicts;
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
- let headBeforeMerge = "";
5762
- try {
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
- if (errorType === "permanent" && options.autoSave !== false) await this.handlePermanentFailure();
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: "Codex AGENTS.md",
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
- const dataLocationResult = await this.checkDataLocation(options.fix);
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-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
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: "Codex AGENTS.md",
7869
+ name: "AGENTS.md",
7630
7870
  status: "ok",
7631
7871
  path: AGENTS_MD_REL
7632
7872
  };
7633
7873
  return {
7634
- name: "Codex AGENTS.md",
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: "Codex AGENTS.md",
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) return {
8055
- name: "Sync consistency",
8056
- status: "warn",
8057
- message: `diverged (${consistency.localAhead} ahead, ${consistency.localBehind} behind)`,
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 with name and description (if provided)
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 guidelineRows = buildTableRows(guidelines);
9624
- if (guidelineRows.length > 0) {
9949
+ const grouped = GUIDELINE_GROUPS.map((group) => ({
9950
+ group,
9951
+ docs: []
9952
+ }));
9953
+ const catchAll = grouped[grouped.length - 1];
9954
+ for (const doc of guidelines) (grouped.find((g) => g.group.match(doc.name)) ?? catchAll)?.docs.push(doc);
9955
+ if (grouped.some((g) => g.docs.length > 0)) {
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
- lines.push("| Name | Description |");
9631
- lines.push("| --- | --- |");
9632
- lines.push(...guidelineRows);
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@${VERSION} prime "$@"
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@${VERSION}"
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)) try {
11791
- await rm(join(scriptsDir, filename));
11792
- scriptsRemoved.push(filename);
11793
- } catch {}
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
- console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
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 targeting = this.resolveTargeting();
11859
- await this.installPortableSkill(cwd);
11860
- const claudeResult = await this.setupClaudeIfDetected(cwd, targeting.claude);
11861
- results.push(claudeResult);
11862
- const codexResult = await this.setupCodexIfDetected(cwd, targeting.codex);
11863
- results.push(codexResult);
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 skipped = results.filter((r) => !r.detected);
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 (skipped.length > 0 && (installed.length > 0 || alreadyInstalled.length > 0)) {
11876
- console.log(colors.dim("Not detected (skipped):"));
11877
- for (const r of skipped) console.log(` ${colors.dim("-")} ${r.name}`);
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
- * Write the canonical portable Agent Skill to .agents/skills/tbd/SKILL.md.
11907
- * Runs for every initialized repo, independent of agent detection, so the
11908
- * skill is portable across Codex, Gemini CLI, Cursor, and other clients.
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
- async installPortableSkill(cwd) {
11911
- const colors = this.output.getColors();
11912
- if (this.checkDryRun("Would install portable Agent Skill", { path: AGENTS_SKILL_DISPLAY })) return;
11913
- const { portable } = getAgentSkillPaths(cwd);
11914
- await writeSkillFile(portable, await buildSkillPayload(this.ctx.quiet));
11915
- console.log(` ${colors.success("✓")} Portable Agent Skill (${AGENTS_SKILL_DISPLAY})`);
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
- * Resolve which agent surfaces to install from the explicit targeting flags.
11919
- * `--all`/`--claude`/`--codex` force surfaces on (and suppress auto-detection
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
- resolveTargeting() {
11924
- const opts = this.cmd.optsWithGlobals();
11925
- const all = opts.all === true;
11926
- const anyPositive = all || opts.claude === true || opts.codex === true;
11927
- const resolve = (on, skip) => {
11928
- if (skip === true) return "off";
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
- async setupClaudeIfDetected(cwd, mode) {
12339
+ /** Install the Claude Code surface: skill mirror + .claude/settings.json hooks. */
12340
+ async installClaudeSurface(cwd) {
11938
12341
  const result = {
11939
- name: "Claude Code",
11940
- detected: false,
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
- async setupCodexIfDetected(cwd, mode) {
12366
+ /** Install the AGENTS.md surface: the tbd managed block only (no Codex hooks). */
12367
+ async installAgentsMdSurface(cwd) {
11970
12368
  const result = {
11971
- name: "Codex/AGENTS.md",
11972
- detected: false,
12369
+ name: SURFACE_DISPLAY_NAME["agents-md"],
12370
+ detected: true,
11973
12371
  installed: false,
11974
12372
  alreadyInstalled: false
11975
12373
  };
11976
- if (mode === "off") return result;
11977
- const agentsPath = getAgentsMdPath(cwd);
11978
- const hasAgentsMd = await pathExists$1(agentsPath);
11979
- if (mode === "auto") {
11980
- const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
11981
- if (!hasAgentsMd && !hasCodexEnv) return result;
11982
- }
11983
- result.detected = true;
11984
- if (hasAgentsMd) {
11985
- if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) result.alreadyInstalled = true;
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.run({});
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("--all", "Install every supported agent surface (Claude + Codex)").option("--claude", "Install the Claude Code surface (skill mirror + hooks)").option("--codex", "Install the Codex surface (AGENTS.md block + .codex hooks)").option("--skip-claude", "Skip the Claude Code surface even if detected").option("--skip-codex", "Skip the Codex surface even if detected").action(async (options, command) => {
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");