@bridge_gpt/mcp-server 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +554 -66
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +682 -81
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +17 -5
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +17 -9
|
@@ -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 {
|
|
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 =
|
|
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
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
303
|
-
|
|
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 {
|
|
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
|
+
}
|