@bridge_gpt/mcp-server 0.2.2 → 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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +468 -59
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +16 -8
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Conductor identity, hook provisioning, and run-level emission for
3
+ * `start-tickets` (BAPI-394, conductor C2 — Claude Code producer run).
4
+ *
5
+ * This module is the seam between the start-tickets orchestration pipeline and
6
+ * the local conductor event ledger. It:
7
+ *
8
+ * - mints a single per-invocation `run_id` and a unique `worker_id` per Claude
9
+ * worker (Langfuse-style `{ticket}-{automation}-{frag}` identifiers),
10
+ * - builds the secret-free per-worker environment injected at the spawn
11
+ * boundary so each spawned Claude session inherits ONLY its own conductor
12
+ * identity,
13
+ * - merges a Claude lifecycle hook registration into each created worktree's
14
+ * `.claude/settings.local.json` (idempotent, preserving existing hooks),
15
+ * - emits one canonical `run.started` event for a real invocation,
16
+ * - renders per-row conductor env into the spawned shell command string.
17
+ *
18
+ * Everything here is secret-free by construction: no API keys, tokens, auth
19
+ * headers, or raw payload material are ever placed in env, hook commands, run
20
+ * metadata, or the shell command string.
21
+ */
22
+ import { randomBytes } from "node:crypto";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
26
+ // ---------------------------------------------------------------------------
27
+ // Identity minting
28
+ // ---------------------------------------------------------------------------
29
+ /** Short lowercase-hex correlation fragment (4 bytes -> 8 hex chars). */
30
+ export function randomCorrelationFragment() {
31
+ return randomBytes(4).toString("hex");
32
+ }
33
+ /** Sanitize an automation/agent segment to `[A-Za-z0-9_-]`. */
34
+ function sanitizeIdSegment(value) {
35
+ return value.replace(/[^A-Za-z0-9_-]/g, "-");
36
+ }
37
+ /**
38
+ * Mint the single per-invocation run id, `{firstTicket}-start-tickets-{frag}`
39
+ * (e.g. `BAPI-392-start-tickets-a1b2c3d4`). The fragment is injectable for
40
+ * deterministic tests.
41
+ */
42
+ export function mintStartTicketsRunId(keys, fragment = randomCorrelationFragment()) {
43
+ const ticket = keys.length > 0 ? keys[0] : "start-tickets";
44
+ return `${ticket}-start-tickets-${fragment}`;
45
+ }
46
+ /**
47
+ * Mint a per-worker id, `{ticket}-{agent}-{frag}` (e.g.
48
+ * `BAPI-392-claude-a1b2c3d4`). The agent segment is sanitized to
49
+ * `[A-Za-z0-9_-]`. The fragment is injectable for deterministic tests.
50
+ */
51
+ export function mintStartTicketsWorkerId(ticketKey, agentName, fragment = randomCorrelationFragment()) {
52
+ return `${ticketKey}-${sanitizeIdSegment(agentName)}-${fragment}`;
53
+ }
54
+ /**
55
+ * Build the secret-free epic identity env keys from an {@link EpicDispatchIdentity}.
56
+ * Strict allowlist — constructs a FRESH object with exactly three named keys,
57
+ * never copies arbitrary parent env so credentials cannot leak.
58
+ */
59
+ export function buildEpicIdentityEnv(epic) {
60
+ return {
61
+ BAPI_CONDUCTOR_EPIC_KEY: epic.epic_key,
62
+ BAPI_CONDUCTOR_EPIC_RUN_ID: epic.epic_run_id,
63
+ BAPI_CONDUCTOR_PLAN_VERSION: String(epic.plan_version),
64
+ };
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Conductor context
68
+ // ---------------------------------------------------------------------------
69
+ /** Default gate name when no `BAPI_CONDUCTOR_GATE_NAME` override is present. */
70
+ export const DEFAULT_CONDUCTOR_GATE_NAME = "implement-ticket";
71
+ /** Resolve a packaged build artifact path relative to this compiled module. */
72
+ function defaultResolveBinPath(filename) {
73
+ return fileURLToPath(new URL(`./${filename}`, import.meta.url));
74
+ }
75
+ function nonEmpty(value) {
76
+ return typeof value === "string" && value.trim().length > 0;
77
+ }
78
+ /**
79
+ * Resolve the conductor context once per invocation, after agent/platform
80
+ * resolution and before any rows are spawned. Repo resolution is fail-soft
81
+ * (`null` on any error). Gate/supervisor honour env overrides; supervisor
82
+ * defaults to `auto` for auto-approved runs and `interactive` otherwise.
83
+ */
84
+ export async function createStartTicketsConductorContext(options, agent, deps) {
85
+ const resolveRepoName = deps.resolveRepoName ?? resolveStartTicketsRepoName;
86
+ let repoName = null;
87
+ try {
88
+ repoName = await resolveRepoName({ env: deps.env, cwd: deps.cwd, readFile: deps.readFile });
89
+ }
90
+ catch {
91
+ // Repo resolution must never abort conductor setup; fall back to null.
92
+ repoName = null;
93
+ }
94
+ const gateName = nonEmpty(deps.env.BAPI_CONDUCTOR_GATE_NAME)
95
+ ? deps.env.BAPI_CONDUCTOR_GATE_NAME.trim()
96
+ : DEFAULT_CONDUCTOR_GATE_NAME;
97
+ const supervisorMode = nonEmpty(deps.env.BAPI_CONDUCTOR_SUPERVISOR_MODE)
98
+ ? deps.env.BAPI_CONDUCTOR_SUPERVISOR_MODE.trim()
99
+ : options.autoApprove
100
+ ? "auto"
101
+ : "interactive";
102
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
103
+ const context = {
104
+ runId: mintStartTicketsRunId(options.keys, deps.fragment),
105
+ repoName,
106
+ gateName,
107
+ supervisorMode,
108
+ agentName: agent.name,
109
+ cliFile: resolveBinPath("conductor-bin.js"),
110
+ hookBinPath: resolveBinPath("conductor-claude-hook-bin.js"),
111
+ };
112
+ if (options.epic) {
113
+ context.epic = options.epic;
114
+ }
115
+ return context;
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Per-worker environment
119
+ // ---------------------------------------------------------------------------
120
+ /** Conductor store-tuning env keys forwarded only when already set upstream. */
121
+ const CONDUCTOR_TUNING_ENV_KEYS = [
122
+ "BAPI_CONDUCTOR_BUSY_TIMEOUT_MS",
123
+ "BAPI_CONDUCTOR_RETENTION_DAYS",
124
+ "BAPI_CONDUCTOR_RETENTION_MAX_ROWS",
125
+ // BAPI-397: per-type relay cooldown — forwarded only when explicitly set so a
126
+ // worker's check_messages cooldown probe matches the supervisor's send config.
127
+ "BAPI_CONDUCTOR_MESSAGE_COOLDOWN_MS",
128
+ ];
129
+ /** Truthy predicate for opt-in conductor boolean flags (`1` / `true`). */
130
+ export function isConductorFlagEnabled(value) {
131
+ if (typeof value !== "string")
132
+ return false;
133
+ const v = value.trim().toLowerCase();
134
+ return v === "1" || v === "true";
135
+ }
136
+ /**
137
+ * Build the secret-free per-worker conductor environment. Constructs a FRESH
138
+ * object containing only the allowlisted conductor keys — it never copies
139
+ * arbitrary parent env, so secrets/tokens/headers cannot leak. `PreToolUse`
140
+ * propagation and store-tuning keys are forwarded only when explicitly present
141
+ * upstream.
142
+ */
143
+ export function buildConductorWorkerEnv(context, worker, parentEnv) {
144
+ const env = {
145
+ BAPI_CONDUCTOR_ENABLED: "1",
146
+ BAPI_CONDUCTOR_RUN_ID: context.runId,
147
+ BAPI_CONDUCTOR_WORKER_ID: worker.workerId,
148
+ BAPI_CONDUCTOR_TICKET_KEY: worker.ticketKey,
149
+ BAPI_CONDUCTOR_WORKTREE_PATH: worker.worktreePath,
150
+ BAPI_CONDUCTOR_GATE_NAME: context.gateName,
151
+ BAPI_CONDUCTOR_SUPERVISOR_MODE: context.supervisorMode,
152
+ BAPI_CONDUCTOR_CLI_FILE: context.cliFile,
153
+ };
154
+ if (context.repoName) {
155
+ env.BAPI_CONDUCTOR_REPO_NAME = context.repoName;
156
+ }
157
+ if (context.epic) {
158
+ const epicEnv = buildEpicIdentityEnv(context.epic);
159
+ for (const [k, v] of Object.entries(epicEnv)) {
160
+ env[k] = v;
161
+ }
162
+ }
163
+ if (isConductorFlagEnabled(parentEnv.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE)) {
164
+ env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE = "1";
165
+ }
166
+ for (const key of CONDUCTOR_TUNING_ENV_KEYS) {
167
+ if (nonEmpty(parentEnv[key])) {
168
+ env[key] = parentEnv[key].trim();
169
+ }
170
+ }
171
+ return env;
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // Claude hook settings merge / provisioning
175
+ // ---------------------------------------------------------------------------
176
+ /** Claude lifecycle events always registered for the conductor hook. */
177
+ export const CONDUCTOR_HOOK_LIFECYCLE_EVENTS = [
178
+ "SessionStart",
179
+ "Stop",
180
+ "SubagentStop",
181
+ "Notification",
182
+ ];
183
+ /** Broad matcher used for `PreToolUse` when no existing matcher is present. */
184
+ export const DEFAULT_PRE_TOOL_USE_MATCHER = "*";
185
+ /** Shell-quote a path for embedding in a hook command string. */
186
+ function shellQuotePath(value) {
187
+ // Single-quote for POSIX safety; embedded single quotes are escaped. Hook
188
+ // command strings are interpreted by Claude Code's shell.
189
+ return `'${value.replace(/'/g, "'\\''")}'`;
190
+ }
191
+ /**
192
+ * Resolve the hook command string. Prefers `BAPI_CONDUCTOR_HOOK_COMMAND`
193
+ * verbatim; otherwise runs the packaged hook bin via the current Node
194
+ * executable.
195
+ */
196
+ export function resolveConductorHookCommand(env, hookBinPath, execPath = process.execPath) {
197
+ if (nonEmpty(env.BAPI_CONDUCTOR_HOOK_COMMAND)) {
198
+ return env.BAPI_CONDUCTOR_HOOK_COMMAND;
199
+ }
200
+ return `${shellQuotePath(execPath)} ${shellQuotePath(hookBinPath)}`;
201
+ }
202
+ function asHookEntries(value) {
203
+ return Array.isArray(value) ? value : [];
204
+ }
205
+ /** Does this event already register `command` as a command hook? */
206
+ function entriesContainCommand(entries, command) {
207
+ return entries.some((entry) => Array.isArray(entry?.hooks) &&
208
+ entry.hooks.some((h) => h && h.type === "command" && h.command === command));
209
+ }
210
+ /**
211
+ * Merge conductor lifecycle hooks into an existing Claude settings object
212
+ * WITHOUT removing any existing settings or hooks. Idempotent: re-applying the
213
+ * same command never duplicates an entry. `PreToolUse` is registered only when
214
+ * `enablePreToolUse` is true, mirroring any existing matcher style.
215
+ */
216
+ export function mergeClaudeSettingsWithConductorHook(settings, command, options = {}) {
217
+ const existingHooks = settings.hooks !== null && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
218
+ ? settings.hooks
219
+ : {};
220
+ const hooks = { ...existingHooks };
221
+ const events = [...CONDUCTOR_HOOK_LIFECYCLE_EVENTS];
222
+ if (options.enablePreToolUse) {
223
+ events.push("PreToolUse");
224
+ }
225
+ for (const event of events) {
226
+ const entries = asHookEntries(hooks[event]);
227
+ if (entriesContainCommand(entries, command)) {
228
+ // Idempotent: already registered for this event.
229
+ hooks[event] = entries;
230
+ continue;
231
+ }
232
+ const newEntry = { hooks: [{ type: "command", command }] };
233
+ if (event === "PreToolUse") {
234
+ newEntry.matcher = options.preToolUseMatcher ?? DEFAULT_PRE_TOOL_USE_MATCHER;
235
+ }
236
+ hooks[event] = [...entries, newEntry];
237
+ }
238
+ return { ...settings, hooks };
239
+ }
240
+ /** Detect an existing PreToolUse matcher to mirror, if any. */
241
+ function detectExistingPreToolUseMatcher(settings) {
242
+ const hooks = settings.hooks;
243
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
244
+ return undefined;
245
+ const entries = asHookEntries(hooks.PreToolUse);
246
+ for (const entry of entries) {
247
+ if (typeof entry?.matcher === "string")
248
+ return entry.matcher;
249
+ }
250
+ return undefined;
251
+ }
252
+ /**
253
+ * Provision the conductor Claude hook into ONE worktree's
254
+ * `.claude/settings.local.json`. Missing settings are treated as `{}`; malformed
255
+ * JSON is a per-row safety failure (the user's file is never overwritten). Writes
256
+ * pretty-printed JSON, mirroring any existing PreToolUse matcher.
257
+ */
258
+ export async function provisionConductorHookForWorktree(worktreePath, command, options, deps) {
259
+ const claudeDir = path.join(worktreePath, ".claude");
260
+ const settingsPath = path.join(claudeDir, "settings.local.json");
261
+ let existing = {};
262
+ let raw = null;
263
+ try {
264
+ raw = await deps.readFile(settingsPath);
265
+ }
266
+ catch {
267
+ // Missing file -> treat as empty settings.
268
+ raw = null;
269
+ }
270
+ if (raw !== null) {
271
+ try {
272
+ const parsed = JSON.parse(raw);
273
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
274
+ return {
275
+ ok: false,
276
+ reason: "malformed",
277
+ error: "existing .claude/settings.local.json is not a JSON object",
278
+ };
279
+ }
280
+ existing = parsed;
281
+ }
282
+ catch {
283
+ // Malformed JSON: fail the row rather than clobber the user's file. The
284
+ // error is generic — it never echoes the file contents.
285
+ return {
286
+ ok: false,
287
+ reason: "malformed",
288
+ error: "existing .claude/settings.local.json contains invalid JSON",
289
+ };
290
+ }
291
+ }
292
+ const merged = mergeClaudeSettingsWithConductorHook(existing, command, {
293
+ enablePreToolUse: options.enablePreToolUse,
294
+ preToolUseMatcher: options.preToolUseMatcher ?? detectExistingPreToolUseMatcher(existing),
295
+ });
296
+ try {
297
+ await deps.mkdir(claudeDir, { recursive: true });
298
+ await deps.writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
299
+ }
300
+ catch {
301
+ // Best-effort: a write/mkdir failure must not block the actual work.
302
+ return { ok: false, reason: "io", error: "failed to write .claude/settings.local.json" };
303
+ }
304
+ return { ok: true };
305
+ }
306
+ /**
307
+ * Provision conductor identity across rows. Attaches `runId` to EVERY row (so
308
+ * the run-level event can attribute all of them), and — for created Claude rows
309
+ * with a path — mints a `workerId`, builds the per-worker env, and writes the
310
+ * hook file. Non-Claude rows get the `runId` only (no Claude hook file).
311
+ * Malformed-settings / write failures mark only the affected row `spawn-failed`.
312
+ */
313
+ export async function provisionConductorHooksForRows(rows, context, deps) {
314
+ const isClaude = context.agentName === "claude";
315
+ const enablePreToolUse = isConductorFlagEnabled(deps.env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE);
316
+ const command = resolveConductorHookCommand(deps.env, context.hookBinPath, deps.execPath);
317
+ const fragment = deps.workerFragment;
318
+ const out = [];
319
+ for (const row of rows) {
320
+ // Every row carries the run id.
321
+ const base = { ...row, runId: context.runId };
322
+ if (row.status !== "created" || !row.path) {
323
+ out.push(base);
324
+ continue;
325
+ }
326
+ if (!isClaude) {
327
+ // Non-Claude agents: run-level event still applies, but no Claude hook
328
+ // file or Claude-only worker env is injected.
329
+ out.push(base);
330
+ continue;
331
+ }
332
+ const workerId = mintStartTicketsWorkerId(row.key, context.agentName, fragment ? fragment() : randomCorrelationFragment());
333
+ const conductorEnv = buildConductorWorkerEnv(context, { workerId, ticketKey: row.key, worktreePath: row.path }, deps.env);
334
+ const result = await provisionConductorHookForWorktree(row.path, command, { enablePreToolUse }, deps);
335
+ if (!result.ok) {
336
+ // Either failure mode — a malformed existing settings file we refuse to
337
+ // clobber, or a best-effort I/O failure — skips hook injection but must
338
+ // NOT cancel the spawn: conductor observability never blocks the actual
339
+ // work (see the fail-open principle documented throughout). We still never
340
+ // overwrite the user's file; we simply attach a non-fatal warning and let
341
+ // the worker spawn.
342
+ out.push({
343
+ ...base,
344
+ workerId,
345
+ warnings: [...(base.warnings ?? []), `conductor hook not injected: ${result.error}`],
346
+ });
347
+ continue;
348
+ }
349
+ out.push({
350
+ ...base,
351
+ workerId,
352
+ conductorEnv,
353
+ conductorHookInjected: true,
354
+ });
355
+ }
356
+ return out;
357
+ }
358
+ // ---------------------------------------------------------------------------
359
+ // run.started emission
360
+ // ---------------------------------------------------------------------------
361
+ /**
362
+ * Build the canonical `run.started` event. Run-level metadata lives under
363
+ * `data.details` (never as non-allowlisted top-level data keys). Subject is the
364
+ * repo name when known, else the first requested ticket key.
365
+ */
366
+ export function buildStartTicketsRunStartedEventInput(context, rows, options) {
367
+ const worktreeRows = rows.filter((r) => typeof r.path === "string" && r.path.length > 0);
368
+ const workers = worktreeRows.map((r) => ({
369
+ ticket_key: r.key,
370
+ worker_id: r.workerId ?? null,
371
+ worktree_path: r.path ?? null,
372
+ status: r.status,
373
+ }));
374
+ const subject = context.repoName ?? (options.keys.length > 0 ? options.keys[0] : "start-tickets");
375
+ return {
376
+ source: "start-tickets",
377
+ type: "run.started",
378
+ run_id: context.runId,
379
+ producer: "bridge-api-mcp-server",
380
+ observed_via: "start-tickets",
381
+ subject,
382
+ data: {
383
+ summary: "start-tickets run started",
384
+ status: "started",
385
+ details: {
386
+ repo: context.repoName,
387
+ requested_ticket_keys: options.keys,
388
+ ticket_keys: worktreeRows.map((r) => r.key),
389
+ worktree_paths: worktreeRows.map((r) => r.path),
390
+ workers,
391
+ gate_name: context.gateName,
392
+ supervisor_mode: context.supervisorMode,
393
+ dry_run: options.dryRun,
394
+ agent: context.agentName,
395
+ ...(context.epic
396
+ ? {
397
+ epic_key: context.epic.epic_key,
398
+ epic_run_id: context.epic.epic_run_id,
399
+ plan_version: context.epic.plan_version,
400
+ }
401
+ : {}),
402
+ },
403
+ },
404
+ };
405
+ }
406
+ /** Generic, secret-free warning appended when run-start emission fails. */
407
+ export const CONDUCTOR_RUN_START_EMIT_FAILED_WARNING = "conductor run-start emit failed (continuing without run-level event)";
408
+ /**
409
+ * Emit the run-level `run.started` event. Best-effort: any emission error
410
+ * appends a generic, secret-free warning to the first row rather than aborting
411
+ * the start-tickets run.
412
+ */
413
+ export async function emitStartTicketsRunStarted(context, rows, options, deps = {}) {
414
+ const event = buildStartTicketsRunStartedEventInput(context, rows, options);
415
+ try {
416
+ if (deps.emit) {
417
+ deps.emit(event);
418
+ }
419
+ else {
420
+ const { emitConductorEvent } = await import("./conductor/store.js");
421
+ emitConductorEvent(event);
422
+ }
423
+ return rows;
424
+ }
425
+ catch {
426
+ if (rows.length === 0)
427
+ return rows;
428
+ const [first, ...rest] = rows;
429
+ const warned = {
430
+ ...first,
431
+ warnings: [...(first.warnings ?? []), CONDUCTOR_RUN_START_EMIT_FAILED_WARNING],
432
+ };
433
+ return [warned, ...rest];
434
+ }
435
+ }
436
+ // ---------------------------------------------------------------------------
437
+ // Shell-command env injection (spawn boundary)
438
+ // ---------------------------------------------------------------------------
439
+ /** Valid env key shape rendered into a shell command. */
440
+ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
441
+ /** POSIX single-quote escape (close, escaped quote, reopen). */
442
+ function posixSingleQuote(value) {
443
+ return `'${value.replace(/'/g, "'\\''")}'`;
444
+ }
445
+ /** PowerShell single-quote escape (double any single quote). */
446
+ function powershellSingleQuote(value) {
447
+ return `'${value.replace(/'/g, "''")}'`;
448
+ }
449
+ /**
450
+ * Prepend per-row conductor env assignments to a spawned shell command so the
451
+ * env is scoped to that one terminal/tab/session only. POSIX uses
452
+ * `export KEY='value';`; Windows uses `$env:KEY='value';`. Invalid keys are
453
+ * skipped. Never mutates process/global env.
454
+ */
455
+ export function injectConductorEnvIntoShellCommand(platform, shellCommand, env) {
456
+ if (!env)
457
+ return shellCommand;
458
+ const entries = Object.entries(env).filter(([key]) => ENV_KEY_PATTERN.test(key));
459
+ if (entries.length === 0)
460
+ return shellCommand;
461
+ const isWindows = platform === "win32";
462
+ const assignments = entries
463
+ .map(([key, value]) => isWindows
464
+ ? `$env:${key}=${powershellSingleQuote(value)};`
465
+ : `export ${key}=${posixSingleQuote(value)};`)
466
+ .join(" ");
467
+ return `${assignments} ${shellCommand}`;
468
+ }
469
+ // ---------------------------------------------------------------------------
470
+ // Supervisor peer-tab launch (BAPI-396, conductor C4)
471
+ // ---------------------------------------------------------------------------
472
+ /** Spawn-context key suffix that identifies the supervisor tab. */
473
+ export const SUPERVISOR_SPAWN_KEY_SUFFIX = "supervisor";
474
+ /**
475
+ * Build the supervisor peer-tab shell command, `node <cliFile> supervise
476
+ * --run-id <runId>`. The node executable, the packaged `cliFile`, and the run id
477
+ * are shell-quoted with the SAME platform helpers used for worker commands
478
+ * (POSIX single-quote / PowerShell single-quote) so paths with spaces are safe.
479
+ */
480
+ export function buildSupervisorTabCommand(context, platform, nodeExecPath = process.execPath) {
481
+ const quote = platform === "win32" ? powershellSingleQuote : posixSingleQuote;
482
+ return `${quote(nodeExecPath)} ${quote(context.cliFile)} supervise --run-id ${quote(context.runId)}`;
483
+ }
484
+ /**
485
+ * Decide whether the supervisor peer tab should launch. Enabled by default for a
486
+ * normal packaged conductor context; disabled only when the resolved supervisor
487
+ * mode is `off` (i.e. `BAPI_CONDUCTOR_SUPERVISOR_MODE=off`).
488
+ */
489
+ export function isSupervisorLaunchEnabled(context) {
490
+ return context.supervisorMode.trim().toLowerCase() !== "off";
491
+ }
492
+ /** Build the spawn-context key that identifies the supervisor tab for a run. */
493
+ export function supervisorSpawnKey(keys) {
494
+ const firstTicket = keys.length > 0 ? keys[0] : "start-tickets";
495
+ return `${firstTicket}-${SUPERVISOR_SPAWN_KEY_SUFFIX}`;
496
+ }
@@ -13,8 +13,8 @@
13
13
  * the runtime graph stays acyclic — `start-tickets.ts` imports values FROM here,
14
14
  * never the reverse.
15
15
  */
16
- import { resolveBapiCredentials } from "./credential-store.js";
17
- import { readBridgeConfig } from "./bridge-config.js";
16
+ import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
17
+ import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
18
18
  import { probeWorktreeMcpRegistration } from "./mcp-registration-doctor.js";
19
19
  // ---------------------------------------------------------------------------
20
20
  // Constants (moved here from start-tickets.ts so both consumers share them)
@@ -254,8 +254,10 @@ function uvDescriptor() {
254
254
  return commandDescriptor("uv", "uv", UV_INSTALL_HINTS);
255
255
  }
256
256
  /** Secret-free remediation hint shared by the credential-resolution descriptor. */
257
- const CREDENTIAL_RESOLUTION_HINT = 'Set BAPI_API_KEY in the environment, or add it under "bapi:<repo_name>" in ' +
258
- "~/.config/bridge/credentials.json.";
257
+ const CREDENTIAL_RESOLUTION_HINT = "Rerun /install-bridge to persist the routing credential, set BAPI_API_KEY in the " +
258
+ 'environment, or add it under "bapi:<repo_name>" in ~/.config/bridge/credentials.json. ' +
259
+ "To migrate a key that only lives in .mcp.json / .cursor/mcp.json, run: " +
260
+ "npx -y @bridge_gpt/mcp-server credentials migrate-agent-config --write-credentials.";
259
261
  const CREDENTIAL_RESOLUTION_INSTALL_HINTS = {
260
262
  darwin: CREDENTIAL_RESOLUTION_HINT,
261
263
  linux: CREDENTIAL_RESOLUTION_HINT,
@@ -263,9 +265,11 @@ const CREDENTIAL_RESOLUTION_INSTALL_HINTS = {
263
265
  };
264
266
  /**
265
267
  * Doctor-only, strictly read-only probe: can the Bridge API credentials be
266
- * resolved for the current repo? Resolves repo identity from `BAPI_REPO_NAME`
267
- * first, then `.bridge/config`, then asks the credential resolver. Reports only
268
- * the SOURCE CLASS (env vs. store) NEVER the resolved key value.
268
+ * resolved for the current repo? Resolves repo identity via the SHARED
269
+ * {@link resolveStartTicketsRepoName} helper (so it can never drift from what
270
+ * `start-tickets` itself uses), then asks the credential resolver. Reports the
271
+ * exact resolver path/source — env vs. `store target bapi:<repo>` at the primary
272
+ * store path — but NEVER the resolved key value. This probe performs NO writes.
269
273
  */
270
274
  export function credentialResolutionDescriptor() {
271
275
  return {
@@ -277,20 +281,19 @@ export function credentialResolutionDescriptor() {
277
281
  if (!readFile || !stat || !homedir) {
278
282
  return { found: false, detail: "credential probe unavailable (no read-only filesystem access)" };
279
283
  }
280
- let repoName = (deps.env.BAPI_REPO_NAME ?? "").trim();
281
- if (repoName.length === 0) {
282
- const read = await readBridgeConfig(deps.cwd, { readFile });
283
- if (read.ok) {
284
- repoName = read.manifest.repoName;
285
- }
286
- else {
287
- return {
288
- found: false,
289
- detail: "cannot determine repo identity (set BAPI_REPO_NAME or add a valid .bridge/config). " +
290
- CREDENTIAL_RESOLUTION_HINT,
291
- };
292
- }
284
+ const repoName = await resolveStartTicketsRepoName({
285
+ env: deps.env,
286
+ cwd: deps.cwd,
287
+ readFile,
288
+ });
289
+ if (!repoName) {
290
+ return {
291
+ found: false,
292
+ detail: "cannot determine repo identity (set BAPI_REPO_NAME or add a valid .bridge/config). " +
293
+ CREDENTIAL_RESOLUTION_HINT,
294
+ };
293
295
  }
296
+ const storePath = getPrimaryCredentialStorePath({ env: deps.env, homedir });
294
297
  const result = await resolveBapiCredentials(repoName, {
295
298
  env: deps.env,
296
299
  homedir,
@@ -299,10 +302,16 @@ export function credentialResolutionDescriptor() {
299
302
  stat,
300
303
  });
301
304
  if (result.ok) {
302
- const via = result.credentials.source === "env" ? "env" : "store";
303
- return { found: true, detail: `credentials resolvable via ${via}` };
305
+ const detail = result.credentials.source === "env"
306
+ ? `credentials resolvable via env for repo ${repoName}`
307
+ : `credentials resolvable via store target bapi:${repoName} at ${storePath}`;
308
+ return { found: true, detail };
304
309
  }
305
- return { found: false, detail: CREDENTIAL_RESOLUTION_HINT };
310
+ return {
311
+ found: false,
312
+ detail: `no usable BAPI_API_KEY for bapi:${repoName} (store path ${storePath}). ` +
313
+ CREDENTIAL_RESOLUTION_HINT,
314
+ };
306
315
  },
307
316
  };
308
317
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared repo-name discovery for `start-tickets`, `doctor`, credential migration,
3
+ * and install-time persistence.
4
+ *
5
+ * Keeping repo identity resolution in ONE module guarantees the `bapi:<repo>`
6
+ * store-target identity cannot drift between the credential writer, the resolver,
7
+ * the doctor probe, and the routing layer. The resolution order is always:
8
+ * 1. a trimmed, non-empty `BAPI_REPO_NAME` env value, then
9
+ * 2. `repoName` from `.bridge/config` under the working directory.
10
+ */
11
+ import { readBridgeConfig } from "./bridge-config.js";
12
+ /**
13
+ * Resolve the repo name: prefer a trimmed, non-empty `BAPI_REPO_NAME`, then
14
+ * `.bridge/config` under `deps.cwd`. Returns `null` when neither is available.
15
+ * Never throws — a malformed/unreadable config simply falls through to `null`.
16
+ */
17
+ export async function resolveStartTicketsRepoName(deps) {
18
+ const fromEnv = deps.env.BAPI_REPO_NAME;
19
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
20
+ return fromEnv.trim();
21
+ }
22
+ try {
23
+ const result = await readBridgeConfig(deps.cwd, { readFile: deps.readFile });
24
+ if (result.ok && result.manifest.repoName) {
25
+ return result.manifest.repoName;
26
+ }
27
+ }
28
+ catch {
29
+ // A read/parse failure is non-fatal here — fall through to null. The error
30
+ // is never surfaced (it could echo file contents), keeping this secret-free.
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Required variant: returns a structured, secret-free `repo-missing` failure when
36
+ * neither env nor `.bridge/config` resolves the repo. Use this where the caller
37
+ * must distinguish "no repo identity" from other routing/credential failures.
38
+ */
39
+ export async function resolveRequiredStartTicketsRepoName(deps) {
40
+ const repoName = await resolveStartTicketsRepoName(deps);
41
+ if (!repoName) {
42
+ return {
43
+ ok: false,
44
+ kind: "repo-missing",
45
+ error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
46
+ };
47
+ }
48
+ return { ok: true, repoName };
49
+ }