@bridge_gpt/mcp-server 0.2.30 → 0.2.31

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Read-only worktree Claude command-asset diagnostic (doctor-only, BAPI-664).
3
+ *
4
+ * Reports whether the current worktree contains every packaged Claude slash
5
+ * command under `.claude/commands/`. This probe ONLY reads command paths — it
6
+ * never writes, creates directories, invokes Git, spawns a process, compares or
7
+ * reports command file CONTENTS, or leaks raw exceptions. A committed customer
8
+ * version of a command counts as present regardless of its contents (the command
9
+ * bundle is fill-only and never refreshed), so the probe checks presence /
10
+ * readability, not freshness. The sole injected dependencies are `readFile` and
11
+ * `platform`.
12
+ */
13
+ import path from "path";
14
+ import { COMMANDS } from "./commands.generated.js";
15
+ /** Stable user-facing relative location shown in every detail string. */
16
+ const COMMAND_DIR_LABEL = ".claude/commands/";
17
+ /** How many missing filenames to list before summarizing the remainder. */
18
+ const MAX_LISTED_MISSING = 5;
19
+ /** The `path` API for the target platform (win32 vs posix). */
20
+ function pathApiForPlatform(platform) {
21
+ return platform === "win32" ? path.win32 : path.posix;
22
+ }
23
+ /** Render a bounded, deterministic list of missing filenames (generated order). */
24
+ function formatMissing(missing) {
25
+ if (missing.length <= MAX_LISTED_MISSING) {
26
+ return missing.join(", ");
27
+ }
28
+ const shown = missing.slice(0, MAX_LISTED_MISSING).join(", ");
29
+ return `${shown} (+${missing.length - MAX_LISTED_MISSING} more)`;
30
+ }
31
+ /**
32
+ * Probe whether `<worktreeRoot>/.claude/commands/` contains every packaged
33
+ * command asset. A `readFile` that resolves counts the asset as present (any
34
+ * contents); a rejection (missing OR unreadable) counts it as absent. Returns
35
+ * `found: true` only when every packaged command filename is present, otherwise a
36
+ * `found: false` result with a concise, actionable, secret-free detail that
37
+ * points the operator back to `start-tickets` (never to manually copying or
38
+ * committing generated files).
39
+ */
40
+ export async function probeWorktreeCommandAssets(worktreeRoot, deps) {
41
+ const filenames = Object.keys(COMMANDS);
42
+ if (filenames.length === 0) {
43
+ return {
44
+ found: false,
45
+ detail: "Packaged command bundle is empty — reinstall or rebuild the MCP server package.",
46
+ };
47
+ }
48
+ const api = pathApiForPlatform(deps.platform);
49
+ const commandsDir = api.join(worktreeRoot, ".claude", "commands");
50
+ const missing = [];
51
+ for (const filename of filenames) {
52
+ try {
53
+ await deps.readFile(api.join(commandsDir, filename));
54
+ }
55
+ catch {
56
+ // Missing OR unreadable — either way the asset is not usable. We never
57
+ // surface the underlying error (it can carry paths / exception text).
58
+ missing.push(filename);
59
+ }
60
+ }
61
+ if (missing.length === 0) {
62
+ return {
63
+ found: true,
64
+ detail: `${filenames.length} packaged command assets present under ${COMMAND_DIR_LABEL}`,
65
+ };
66
+ }
67
+ return {
68
+ found: false,
69
+ detail: `${missing.length} of ${filenames.length} packaged command assets missing or unreadable under ` +
70
+ `${COMMAND_DIR_LABEL} (${formatMissing(missing)}). Re-run start-tickets to provision them.`,
71
+ };
72
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Shared Claude command-asset provisioning (BAPI-664).
3
+ *
4
+ * Materializes the FULL packaged command bundle (`COMMANDS` from
5
+ * `commands.generated.ts`, the single source of truth) into a worktree's
6
+ * `.claude/commands/` directory so every packaged slash command is runnable from
7
+ * a freshly created worktree — whether spawned by the interactive `start-tickets`
8
+ * orchestration or by the headless conductor executor.
9
+ *
10
+ * Semantics (locked ticket decisions):
11
+ * - FULL bundle: every entry in `COMMANDS` is provisioned.
12
+ * - FILL-ONLY: a command file that already exists is treated as a customer asset
13
+ * and left byte-for-byte unchanged — its contents are never read-compared or
14
+ * refreshed. Only a missing (`ENOENT`) file is created. There is no refresh
15
+ * flag.
16
+ * - Repository-common exclude: after materialization, `.claude/commands/` is
17
+ * added to the worktree's Git `info/exclude` (resolved via Git for linked
18
+ * worktrees). `info/exclude` lives in the common Git directory shared by
19
+ * sibling worktrees, and an ignore rule never hides an already-tracked file,
20
+ * so this common scope is safe.
21
+ *
22
+ * All filesystem + command access is dependency-injected so this is unit-testable
23
+ * with no real I/O. This module NEVER imports `node:fs` / `child_process`, writes
24
+ * to stdout/stderr, or registers an MCP tool — failures are surfaced structurally
25
+ * and rendered by the caller's existing orchestration boundary.
26
+ */
27
+ import path from "path";
28
+ import { COMMANDS } from "./commands.generated.js";
29
+ import { ensureGitInfoExcluded } from "./git-ignore-utils.js";
30
+ /** The exact exclude entry appended for the command directory (POSIX-relative). */
31
+ const COMMAND_DIR_EXCLUDE_ENTRY = ".claude/commands/";
32
+ /** Bounded, secret-free error surfaced when the packaged bundle is empty. */
33
+ const EMPTY_BUNDLE_ERROR = "Command provisioning failed: the packaged command bundle is empty — reinstall or rebuild the MCP server package.";
34
+ /**
35
+ * Resolve the path API for the target platform. Local (not imported from
36
+ * `start-tickets.ts`) to avoid a runtime import cycle.
37
+ */
38
+ export function pathApiForCommandProvisioningPlatform(platform) {
39
+ return platform === "win32" ? path.win32 : path.posix;
40
+ }
41
+ /** True only for a Node `ENOENT` (missing-file) error. */
42
+ function isEnoentError(err) {
43
+ return (typeof err === "object" &&
44
+ err !== null &&
45
+ err.code === "ENOENT");
46
+ }
47
+ /**
48
+ * Fill every MISSING packaged command asset into `<worktreeRoot>/.claude/commands/`
49
+ * without touching any existing customer file, then ensure `.claude/commands/` is
50
+ * added to the worktree's Git exclude. Returns a structured result; never throws
51
+ * for an expected filesystem/Git failure, and never leaks raw exception text,
52
+ * command output, or file contents.
53
+ */
54
+ export async function provisionCommandsForWorktree(worktreeRoot, deps) {
55
+ const api = pathApiForCommandProvisioningPlatform(deps.platform);
56
+ const normalizedRoot = api.isAbsolute(worktreeRoot)
57
+ ? api.normalize(worktreeRoot)
58
+ : api.resolve(deps.cwd, worktreeRoot);
59
+ const commandsDir = api.join(normalizedRoot, ".claude", "commands");
60
+ const entries = Object.entries(COMMANDS);
61
+ if (entries.length === 0) {
62
+ // No runnable slash commands could ever be provisioned — fail loudly rather
63
+ // than reporting a hollow success.
64
+ return { ok: false, error: EMPTY_BUNDLE_ERROR };
65
+ }
66
+ // Phase 1 — discover which packaged files are absent. A successful read means
67
+ // the customer already owns that asset; leave it untouched. Only ENOENT counts
68
+ // as "missing"; any other read failure is a bounded, secret-free error.
69
+ let fillError = null;
70
+ const missing = [];
71
+ for (const [filename, content] of entries) {
72
+ const target = api.join(commandsDir, filename);
73
+ try {
74
+ await deps.readFile(target);
75
+ }
76
+ catch (err) {
77
+ if (isEnoentError(err)) {
78
+ missing.push([filename, content]);
79
+ }
80
+ else {
81
+ fillError = `Command provisioning failed: could not read existing command asset '${filename}'.`;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ // Phase 2 — create the directory (only when something is missing) and write the
87
+ // absent files in bundle order using the packaged string unchanged.
88
+ if (!fillError && missing.length > 0) {
89
+ try {
90
+ await deps.mkdir(commandsDir, { recursive: true });
91
+ for (const [filename, content] of missing) {
92
+ await deps.writeFile(api.join(commandsDir, filename), content);
93
+ }
94
+ }
95
+ catch {
96
+ fillError =
97
+ "Command provisioning failed: could not write one or more packaged command assets.";
98
+ }
99
+ }
100
+ // Phase 3 — ALWAYS attempt exclusion, even after a partial or failed fill, so
101
+ // partially bootstrapped files are never left visible to Git. The primary fill
102
+ // failure is preserved if both the fill and the exclusion fail.
103
+ let excludeError = null;
104
+ try {
105
+ await ensureGitInfoExcluded(normalizedRoot, COMMAND_DIR_EXCLUDE_ENTRY, {
106
+ readFile: deps.readFile,
107
+ writeFile: deps.writeFile,
108
+ mkdir: deps.mkdir,
109
+ runCommand: deps.runCommand,
110
+ platform: deps.platform,
111
+ });
112
+ }
113
+ catch {
114
+ excludeError =
115
+ "Command provisioning failed: could not add '.claude/commands/' to the worktree Git exclude file.";
116
+ }
117
+ if (fillError)
118
+ return { ok: false, error: fillError };
119
+ if (excludeError)
120
+ return { ok: false, error: excludeError };
121
+ return { ok: true };
122
+ }
123
+ /**
124
+ * Provision command assets for every eligible (`created`, path-bearing) row, in
125
+ * input order, serially. A non-`created` row or a `created` row without a usable
126
+ * path is returned unchanged. A per-worktree bootstrap failure marks ONLY that
127
+ * row `spawn-failed` (with a secret-free `Command provisioning failed: …` error)
128
+ * so the affected worker is skipped by all later spawn logic while its siblings
129
+ * proceed. A defensive per-row catch guarantees one unexpected failure cannot
130
+ * abort later rows or reject the overall call.
131
+ */
132
+ export async function provisionCommandsForCreatedWorktrees(rows, deps) {
133
+ const out = [];
134
+ for (const row of rows) {
135
+ if (row.status !== "created" || !row.path) {
136
+ out.push(row);
137
+ continue;
138
+ }
139
+ try {
140
+ const result = await provisionCommandsForWorktree(row.path, deps);
141
+ if (result.ok) {
142
+ out.push(row);
143
+ }
144
+ else {
145
+ out.push({ ...row, status: "spawn-failed", error: result.error });
146
+ }
147
+ }
148
+ catch {
149
+ out.push({
150
+ ...row,
151
+ status: "spawn-failed",
152
+ error: "Command provisioning failed: an unexpected error occurred while bootstrapping worktree command assets.",
153
+ });
154
+ }
155
+ }
156
+ return out;
157
+ }
@@ -73,7 +73,9 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
73
73
  throw new Error("spawnProcess not configured for this test");
74
74
  },
75
75
  readFile: async () => {
76
- throw new Error("ENOENT");
76
+ // Mirror real fs/promises: a missing-file rejection carries code "ENOENT"
77
+ // (so BAPI-664 command provisioning treats absent files as fillable).
78
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
77
79
  },
78
80
  writeFile: async () => { },
79
81
  mkdir: async () => undefined,
@@ -9,6 +9,7 @@
9
9
  import { fetchAndResolveBaseSha } from "../base-ref.js";
10
10
  import { commandSucceeded } from "../start-tickets-prereqs.js";
11
11
  import { createWorktreeForTicket } from "../worktree-core.js";
12
+ import { provisionCommandsForWorktree } from "../command-provisioning.js";
12
13
  /**
13
14
  * Resolve the branch for a real spawn job: `expected_branch` wins; else
14
15
  * `feature/<ticket_key>`; else a structured contract failure.
@@ -35,6 +36,34 @@ function toWorktreeCoreDeps(deps) {
35
36
  cwd: deps.cwd,
36
37
  };
37
38
  }
39
+ /** Build the command-provisioning boundary from the executor deps (BAPI-664). */
40
+ function toCommandProvisioningDeps(deps) {
41
+ return {
42
+ readFile: deps.readFile,
43
+ writeFile: deps.writeFile,
44
+ mkdir: deps.mkdir,
45
+ runCommand: deps.runCommand,
46
+ platform: deps.platform,
47
+ cwd: deps.cwd,
48
+ };
49
+ }
50
+ /**
51
+ * Finalize a resolved executor worktree by bootstrapping the packaged Claude
52
+ * command assets into it (BAPI-664) BEFORE the worker process is handed the
53
+ * worktree path. A provisioning failure is mapped into the executor's existing
54
+ * bounded non-success worktree result so the affected worker is not launched with
55
+ * a missing slash command, while the executor process stays available for later
56
+ * jobs. On success the existing successful ensure-worktree result shape is
57
+ * preserved unchanged. Errors stay secret-free (no raw filesystem exception, Git
58
+ * output, or command contents).
59
+ */
60
+ async function finalizeExecutorWorktree(worktreePath, branch, deps) {
61
+ const provisioned = await provisionCommandsForWorktree(worktreePath, toCommandProvisioningDeps(deps));
62
+ if (!provisioned.ok) {
63
+ return { ok: false, error: provisioned.error };
64
+ }
65
+ return { ok: true, worktreePath, branch };
66
+ }
38
67
  /**
39
68
  * Ensure the worktree for a job exists, delegating to the shared Worktrunk
40
69
  * primitive. A create failure is returned as a structured failure that the job
@@ -92,7 +121,7 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
92
121
  // (= `origin/<branch>`) inside the primitive, so both land at origin.
93
122
  { freshenFromOrigin: originRef });
94
123
  if (row.status === "created" && typeof row.path === "string") {
95
- return { ok: true, worktreePath: row.path, branch };
124
+ return finalizeExecutorWorktree(row.path, branch, deps);
96
125
  }
97
126
  return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
98
127
  }
@@ -117,7 +146,7 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
117
146
  const baseSha = resolvedBase.base_sha;
118
147
  const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseSha, guardStaleWorktree, { alignExistingBranchTo: baseSha, verifyHeadMatches: baseSha });
119
148
  if (row.status === "created" && typeof row.path === "string") {
120
- return { ok: true, worktreePath: row.path, branch };
149
+ return finalizeExecutorWorktree(row.path, branch, deps);
121
150
  }
122
151
  return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
123
152
  }
@@ -4,22 +4,49 @@
4
4
  * Two idempotent, exact-line appenders:
5
5
  * - `ensureGitignored` writes the tracked `<cwd>/.gitignore` (shared with the
6
6
  * existing `--init` scaffolding behavior).
7
- * - `ensureGitInfoExcluded` writes the UNtracked `<worktreeRoot>/.git/info/exclude`,
8
- * used for Tier-3 file credentials that must never enter version control and
9
- * must never touch the tracked `.gitignore`.
7
+ * - `ensureGitInfoExcluded` writes the UNtracked, repository-common
8
+ * `info/exclude`, used for Tier-3 file credentials and (BAPI-664) bootstrapped
9
+ * Claude command assets that must never enter version control and must never
10
+ * touch the tracked `.gitignore`.
11
+ *
12
+ * Linked-worktree note (BAPI-664): a worktree created by `git worktree add` (as
13
+ * Worktrunk does) does NOT contain a `.git` directory — it contains a `.git`
14
+ * *file* that points at the common Git directory. So the historical
15
+ * `<worktreeRoot>/.git/info/exclude` join is WRONG for a linked worktree. When a
16
+ * list-based `runCommand` and `platform` are injected, this helper asks Git for
17
+ * the authoritative path with `git rev-parse --git-path info/exclude` (relative
18
+ * to the worktree root); `info/exclude` lives in the common Git directory shared
19
+ * by all sibling worktrees, and an ignore rule never hides an already-tracked
20
+ * file, so the shared scope is safe. When `runCommand`/`platform` are absent the
21
+ * helper falls back to the legacy `<worktreeRoot>/.git/info/exclude` join, which
22
+ * remains correct for a main repository whose `.git` is a real directory (the
23
+ * SFCC dw.json caller relies on this).
10
24
  *
11
25
  * Both are dependency-injected so they are unit-testable without real I/O, and
12
- * neither ever includes credential file contents in an error.
26
+ * neither ever includes credential file contents or raw Git/command output —
27
+ * in an error.
13
28
  */
14
29
  import path from "path";
30
+ /** The `path` API for the target platform (win32 vs posix). */
31
+ function pathApiForPlatform(platform) {
32
+ return platform === "win32" ? path.win32 : path.posix;
33
+ }
15
34
  /** True when `content` already contains `entry` as its own exact (trimmed) line. */
16
35
  function hasExactLine(content, entry) {
17
36
  return content.split("\n").some((line) => line.trim() === entry);
18
37
  }
19
- /** Append `entry` to `content`, inserting a separating newline only if needed. */
38
+ /** Detect the newline convention of existing content (CRLF wins if present). */
39
+ function detectNewline(content) {
40
+ return /\r\n/.test(content) ? "\r\n" : "\n";
41
+ }
42
+ /**
43
+ * Append `entry` to `content`, inserting a separating newline only if needed and
44
+ * using the file's own newline convention (CRLF preserved when already present).
45
+ */
20
46
  function appendLine(content, entry) {
21
- const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
22
- return content + separator + entry + "\n";
47
+ const newline = detectNewline(content);
48
+ const separator = content.length > 0 && !content.endsWith("\n") ? newline : "";
49
+ return content + separator + entry + newline;
23
50
  }
24
51
  /**
25
52
  * Idempotently ensure `filePath` is listed in `<cwd>/.gitignore`. An absolute
@@ -41,20 +68,46 @@ export async function ensureGitignored(cwd, filePath, deps) {
41
68
  await deps.writeFile(gitignorePath, appendLine(content, entry));
42
69
  }
43
70
  /**
44
- * Idempotently ensure `relativePath` is listed in the worktree's local-only
45
- * `<worktreeRoot>/.git/info/exclude`. Creates `<worktreeRoot>/.git/info` if it
46
- * does not exist. Matching is exact-line (so `dw.json` is not treated as present
47
- * just because `dw.json.bak` exists). Never edits the tracked `.gitignore`.
71
+ * Resolve the worktree's `info/exclude` path. With an injected `runCommand` +
72
+ * `platform`, ask Git (`git rev-parse --git-path info/exclude`) so linked
73
+ * worktrees resolve to the common Git directory; a Git failure or empty path
74
+ * throws a bounded, output-free error. Without them, fall back to the legacy
75
+ * `<worktreeRoot>/.git/info/exclude` join (correct for a main repository).
48
76
  */
49
- export async function ensureGitInfoExcluded(worktreeRoot, relativePath, deps) {
77
+ async function resolveInfoExcludeLocation(worktreeRoot, deps) {
78
+ if (deps.runCommand && deps.platform) {
79
+ const api = pathApiForPlatform(deps.platform);
80
+ const result = await deps.runCommand("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: worktreeRoot });
81
+ if (result.exitCode !== 0) {
82
+ // Never surface raw Git stdout/stderr (it can echo paths / tokens).
83
+ throw new Error("Failed to resolve the worktree info/exclude path via 'git rev-parse --git-path'.");
84
+ }
85
+ const raw = result.stdout.trim();
86
+ if (raw.length === 0) {
87
+ throw new Error("'git rev-parse --git-path info/exclude' returned an empty path.");
88
+ }
89
+ const excludePath = api.isAbsolute(raw) ? api.normalize(raw) : api.resolve(worktreeRoot, raw);
90
+ return { excludePath, infoDir: api.dirname(excludePath) };
91
+ }
50
92
  const infoDir = path.join(worktreeRoot, ".git", "info");
51
- const excludePath = path.join(infoDir, "exclude");
93
+ return { excludePath: path.join(infoDir, "exclude"), infoDir };
94
+ }
95
+ /**
96
+ * Idempotently ensure `relativePath` is listed in the worktree's local-only,
97
+ * repository-common `info/exclude`. Resolves the exclude path via Git for linked
98
+ * worktrees (see module docstring) and creates its parent directory if missing.
99
+ * Matching is exact-line (so `dw.json` is not treated as present just because
100
+ * `dw.json.bak` exists) and the append respects the file's newline convention.
101
+ * Never edits the tracked `.gitignore`.
102
+ */
103
+ export async function ensureGitInfoExcluded(worktreeRoot, relativePath, deps) {
104
+ const { excludePath, infoDir } = await resolveInfoExcludeLocation(worktreeRoot, deps);
52
105
  let content = "";
53
106
  try {
54
107
  content = await deps.readFile(excludePath);
55
108
  }
56
109
  catch {
57
- /* exclude file (or .git/info) doesn't exist yet */
110
+ /* exclude file (or info dir) doesn't exist yet */
58
111
  }
59
112
  if (hasExactLine(content, relativePath))
60
113
  return;