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