@bridge_gpt/mcp-server 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +558 -63
- 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 +3 -0
- 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 +683 -82
- 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 +18 -6
- 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 +16 -8
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conductor MCP tool registration.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the local SQLite ledger operations as strict, Zod-backed MCP tools
|
|
5
|
+
* that follow the existing Bridge MCP return convention
|
|
6
|
+
* (`{ content: [{ type: "text", text }] }`). All ledger access is LOCAL — these
|
|
7
|
+
* tools never round-trip through the Bridge API HTTP helpers. Every handler is
|
|
8
|
+
* wrapped by {@link withConductorToolErrorHandling} so failures surface as
|
|
9
|
+
* sanitized, structured JSON without raw payloads, stack traces, or secrets.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
|
|
13
|
+
import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
|
|
14
|
+
import { emitConductorEvent, pollConductorEvents, waitForConductorEvent, getSupervisorSnapshot, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
|
|
15
|
+
import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
16
|
+
import { waitForDoneGate } from "./pr-ci-producer.js";
|
|
17
|
+
import { resolveConductorBridgeApiAccess, fetchEpicRunState, ConductorBridgeApiError } from "./bridge-api-client.js";
|
|
18
|
+
/** Build a Zod enum from the semantic taxonomy so arbitrary types are rejected up front. */
|
|
19
|
+
export function buildEventTypeZodEnum() {
|
|
20
|
+
return z.enum(SEMANTIC_EVENT_TYPES);
|
|
21
|
+
}
|
|
22
|
+
/** Allowlisted, parameterized read filter schema (shared by poll + wait). */
|
|
23
|
+
const EventFilterSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
type: buildEventTypeZodEnum().optional(),
|
|
26
|
+
types: z.array(buildEventTypeZodEnum()).optional(),
|
|
27
|
+
source: z.string().optional(),
|
|
28
|
+
run_id: z.string().optional(),
|
|
29
|
+
worker_id: z.string().optional(),
|
|
30
|
+
subject: z.string().optional(),
|
|
31
|
+
producer: z.string().optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
function jsonResult(value) {
|
|
35
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Wrap a tool handler so any thrown value becomes a sanitized structured JSON
|
|
39
|
+
* error envelope (validation -> 400, store-busy -> 503, otherwise 500). Raw
|
|
40
|
+
* input data, stack traces, and secret material are never echoed back.
|
|
41
|
+
*/
|
|
42
|
+
export function withConductorToolErrorHandling(handler) {
|
|
43
|
+
return async (args) => {
|
|
44
|
+
try {
|
|
45
|
+
return await handler(args);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return jsonResult(toConductorErrorEnvelope(error));
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function registerEmitEventTool(registerTool) {
|
|
53
|
+
registerTool("emit_event", {
|
|
54
|
+
annotations: {
|
|
55
|
+
readOnlyHint: false,
|
|
56
|
+
destructiveHint: false,
|
|
57
|
+
idempotentHint: false,
|
|
58
|
+
openWorldHint: false,
|
|
59
|
+
},
|
|
60
|
+
description: "Append a semantic coordination event to the LOCAL conductor ledger (~/.config/bridge/events.db). " +
|
|
61
|
+
"This is a local, append-only event store for multi-agent coordination — it does NOT call the Bridge API. " +
|
|
62
|
+
"Only the fixed semantic event taxonomy is accepted (e.g. run.started, agent.notification, ci.passed). " +
|
|
63
|
+
"Place tool-native fields (branch, commitSha, etc.) under data.raw — non-allowlisted top-level data keys are rejected. " +
|
|
64
|
+
"Secrets are redacted before storage and large payloads must be passed by reference (data.payload_ref / data.references).",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
source: z.string().describe("Logical producer of the event (e.g. 'claude-code', 'git-hook')."),
|
|
67
|
+
type: buildEventTypeZodEnum().describe("Semantic event type from the fixed conductor taxonomy."),
|
|
68
|
+
subject: z.string().optional().describe("Optional subject the event is about (e.g. a ticket key)."),
|
|
69
|
+
run_id: z.string().optional().describe("Optional run/session identifier this event belongs to."),
|
|
70
|
+
worker_id: z.string().optional().describe("Optional worker/agent identifier."),
|
|
71
|
+
producer: z.string().optional().describe("Optional finer-grained producer identity."),
|
|
72
|
+
schema_version: z.number().int().positive().optional().describe("Event schema version (default 1)."),
|
|
73
|
+
time: z.string().optional().describe("Optional ISO-8601 event time (defaults to now)."),
|
|
74
|
+
data: z
|
|
75
|
+
.record(z.string(), z.unknown())
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Normalized event data. Allowed top-level keys: summary, status, message, details, reason, metrics, labels, references, payload_ref, raw. Tool-native fields go under 'raw'."),
|
|
78
|
+
confidence: z.number().min(0).max(1).optional().describe("Optional confidence in [0,1]."),
|
|
79
|
+
observed_via: z.string().optional().describe("Optional channel the event was observed through."),
|
|
80
|
+
},
|
|
81
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
82
|
+
const result = emitConductorEvent({
|
|
83
|
+
source: args.source,
|
|
84
|
+
type: args.type,
|
|
85
|
+
subject: args.subject,
|
|
86
|
+
run_id: args.run_id,
|
|
87
|
+
worker_id: args.worker_id,
|
|
88
|
+
producer: args.producer,
|
|
89
|
+
schema_version: args.schema_version,
|
|
90
|
+
time: args.time,
|
|
91
|
+
data: args.data ?? {},
|
|
92
|
+
confidence: args.confidence,
|
|
93
|
+
observed_via: args.observed_via,
|
|
94
|
+
});
|
|
95
|
+
return jsonResult(result);
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
function registerPollEventsTool(registerTool) {
|
|
99
|
+
registerTool("poll_events", {
|
|
100
|
+
annotations: {
|
|
101
|
+
readOnlyHint: true,
|
|
102
|
+
destructiveHint: false,
|
|
103
|
+
idempotentHint: true,
|
|
104
|
+
openWorldHint: false,
|
|
105
|
+
},
|
|
106
|
+
description: "Read ordered events from the LOCAL conductor ledger starting at an inclusive 'since_seq' cursor. " +
|
|
107
|
+
"Returns compact metadata-first summaries by default (data.raw omitted; raw_keys surfaced) and a 'next_seq' cursor to pass on the next call. " +
|
|
108
|
+
"Set data_mode='full' to retrieve complete (redacted) event data. Local read-only; does not call the Bridge API.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
since_seq: z.number().int().nonnegative().optional().describe("Inclusive sequence cursor (default 1)."),
|
|
111
|
+
filter: EventFilterSchema.optional().describe("Optional allowlisted filter."),
|
|
112
|
+
data_mode: z.enum(["summary", "full"]).optional().describe("Projection mode (default 'summary')."),
|
|
113
|
+
limit: z.number().int().positive().optional().describe("Max events to return (default 100, max 1000)."),
|
|
114
|
+
},
|
|
115
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
116
|
+
const result = pollConductorEvents({
|
|
117
|
+
since_seq: args.since_seq ?? 1,
|
|
118
|
+
filter: args.filter,
|
|
119
|
+
data_mode: args.data_mode ?? "summary",
|
|
120
|
+
limit: args.limit,
|
|
121
|
+
});
|
|
122
|
+
return jsonResult(result);
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
function registerWaitForEventTool(registerTool) {
|
|
126
|
+
registerTool("wait_for_event", {
|
|
127
|
+
annotations: {
|
|
128
|
+
readOnlyHint: true,
|
|
129
|
+
destructiveHint: false,
|
|
130
|
+
idempotentHint: true,
|
|
131
|
+
openWorldHint: false,
|
|
132
|
+
},
|
|
133
|
+
description: "Long-poll the LOCAL conductor ledger: block up to 'timeout_ms' (bounded, max 120000) until events matching the filter appear at/after 'since_seq'. " +
|
|
134
|
+
"Returns the same shape as poll_events plus 'timed_out'. SQLite locks are never held between polls. Local read-only; does not call the Bridge API.",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
since_seq: z.number().int().nonnegative().optional().describe("Inclusive sequence cursor (default 1)."),
|
|
137
|
+
filter: EventFilterSchema.optional().describe("Optional allowlisted filter."),
|
|
138
|
+
data_mode: z.enum(["summary", "full"]).optional().describe("Projection mode (default 'summary')."),
|
|
139
|
+
timeout_ms: z.number().int().nonnegative().optional().describe("Max wait in ms (bounded, max 120000)."),
|
|
140
|
+
limit: z.number().int().positive().optional().describe("Max events to return (default 100, max 1000)."),
|
|
141
|
+
},
|
|
142
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
143
|
+
const result = await waitForConductorEvent({
|
|
144
|
+
since_seq: args.since_seq ?? 1,
|
|
145
|
+
filter: args.filter,
|
|
146
|
+
data_mode: args.data_mode ?? "summary",
|
|
147
|
+
timeout_ms: args.timeout_ms,
|
|
148
|
+
limit: args.limit,
|
|
149
|
+
});
|
|
150
|
+
return jsonResult(result);
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
function registerGetSupervisorSnapshotTool(registerTool) {
|
|
154
|
+
registerTool("get_supervisor_snapshot", {
|
|
155
|
+
annotations: {
|
|
156
|
+
readOnlyHint: true,
|
|
157
|
+
destructiveHint: false,
|
|
158
|
+
idempotentHint: true,
|
|
159
|
+
openWorldHint: false,
|
|
160
|
+
},
|
|
161
|
+
description: "Read the supervisor projection for a run_id from the LOCAL conductor ledger. " +
|
|
162
|
+
"The projection is maintained by the conductor supervisor runtime (`conductor supervise --run-id <id>`), " +
|
|
163
|
+
"which owns the deterministic worker watchdog state; this tool ONLY reads that projection and never derives state from raw events. " +
|
|
164
|
+
"Returns { run_id, status, projection } where projection is null and status is 'unknown' when no projection exists yet. " +
|
|
165
|
+
"Local read-only; does not call the Bridge API.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
run_id: z.string().describe("The run/session identifier to read the supervisor projection for."),
|
|
168
|
+
},
|
|
169
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
170
|
+
const result = getSupervisorSnapshot(args.run_id);
|
|
171
|
+
return jsonResult(result);
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
function registerGetEpicSnapshotTool(registerTool) {
|
|
175
|
+
registerTool("get_epic_snapshot", {
|
|
176
|
+
annotations: {
|
|
177
|
+
readOnlyHint: true,
|
|
178
|
+
destructiveHint: false,
|
|
179
|
+
idempotentHint: true,
|
|
180
|
+
openWorldHint: true,
|
|
181
|
+
},
|
|
182
|
+
description: "Read the Epic Run snapshot for an epic key from the Bridge API. " +
|
|
183
|
+
"Returns the full EpicRunState: the epic run record (status, plan version, lease state, budget/consumed), " +
|
|
184
|
+
"per-ticket status rows, and dispatch rows. " +
|
|
185
|
+
"Returns { epic_key, status: 'unknown', state: null } if the Epic Run does not exist. " +
|
|
186
|
+
"Read-only; never triggers a tick, transitions Jira, or mutates Epic state.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
epic_key: z.string().min(1).describe("The epic identifier to fetch the snapshot for (e.g. EPIC-123)."),
|
|
189
|
+
},
|
|
190
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
191
|
+
const epicKey = args.epic_key;
|
|
192
|
+
const accessResult = await resolveConductorBridgeApiAccess();
|
|
193
|
+
if (!accessResult.ok) {
|
|
194
|
+
throw new ConductorValidationError(accessResult.error);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const result = await fetchEpicRunState(accessResult.access, epicKey);
|
|
198
|
+
return jsonResult(result);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (error instanceof ConductorBridgeApiError && error.status === 404) {
|
|
202
|
+
return jsonResult({ epic_key: epicKey, status: "unknown", state: null });
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
const SHA_PATTERN = /^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$/;
|
|
209
|
+
function registerWaitForDoneGateTool(registerTool) {
|
|
210
|
+
registerTool("wait_for_done_gate", {
|
|
211
|
+
annotations: {
|
|
212
|
+
// Read/write + non-idempotent: this tool can EMIT conductor events
|
|
213
|
+
// (git.pr_opened, ci.passed/failed, gate.met) as a side effect of observing.
|
|
214
|
+
readOnlyHint: false,
|
|
215
|
+
destructiveHint: false,
|
|
216
|
+
idempotentHint: false,
|
|
217
|
+
openWorldHint: true,
|
|
218
|
+
},
|
|
219
|
+
description: "Bounded wait for the conductor done-gate on a pull request. Resolves the PR number + immutable head SHA " +
|
|
220
|
+
"once, polls CI for that SHA on a clamped interval, and emits conductor events (git.pr_opened, ci.passed/ci.failed, " +
|
|
221
|
+
"and gate.met when the configured required CI checks are green). " +
|
|
222
|
+
"This EMITS conductor coordination events only — it does NOT merge the PR, transition Jira, or mutate any repository state. " +
|
|
223
|
+
"Fails closed: an unset/disabled/malformed conductor_done_gate config never produces gate.met.",
|
|
224
|
+
inputSchema: {
|
|
225
|
+
repo_name: z.string().optional().describe("Optional repo name override (defaults to BAPI_REPO_NAME/.bridge/config)."),
|
|
226
|
+
pr_number: z.number().int().positive().optional().describe("Optional explicit PR number (positive integer)."),
|
|
227
|
+
head_sha: z
|
|
228
|
+
.string()
|
|
229
|
+
.regex(SHA_PATTERN)
|
|
230
|
+
.optional()
|
|
231
|
+
.describe("Optional explicit head SHA (40- or 64-character hex)."),
|
|
232
|
+
timeout_ms: z.number().int().nonnegative().optional().describe("Max wait in ms (clamped, max 120000)."),
|
|
233
|
+
poll_interval_ms: z.number().int().nonnegative().optional().describe("CI poll interval in ms (clamped)."),
|
|
234
|
+
worktree_path: z.string().optional().describe("Optional worktree path to resolve git/PR context from."),
|
|
235
|
+
},
|
|
236
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
237
|
+
// Defensive boundary validation (mirrors the schema) so an invalid identifier
|
|
238
|
+
// is a sanitized 400 and never reaches waitForDoneGate.
|
|
239
|
+
if (args.pr_number !== undefined && normalizePrNumber(args.pr_number) === null) {
|
|
240
|
+
throw new ConductorValidationError("'pr_number' must be a positive integer.");
|
|
241
|
+
}
|
|
242
|
+
if (args.head_sha !== undefined && normalizeSha(args.head_sha) === null) {
|
|
243
|
+
throw new ConductorValidationError("'head_sha' must be a 40- or 64-character hex SHA.");
|
|
244
|
+
}
|
|
245
|
+
const result = await waitForDoneGate({
|
|
246
|
+
repoName: args.repo_name,
|
|
247
|
+
prNumber: args.pr_number,
|
|
248
|
+
headSha: args.head_sha,
|
|
249
|
+
timeoutMs: args.timeout_ms,
|
|
250
|
+
pollIntervalMs: args.poll_interval_ms,
|
|
251
|
+
worktreePath: args.worktree_path,
|
|
252
|
+
});
|
|
253
|
+
return jsonResult({
|
|
254
|
+
gate_met: result.gate_met,
|
|
255
|
+
timed_out: result.timed_out,
|
|
256
|
+
reason: result.reason,
|
|
257
|
+
repo: result.repo,
|
|
258
|
+
pr_number: result.pr_number,
|
|
259
|
+
head_sha: result.head_sha,
|
|
260
|
+
gate_event_summary: result.gate_event_summary,
|
|
261
|
+
});
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
function registerSendMessageTool(registerTool) {
|
|
265
|
+
registerTool("send_message", {
|
|
266
|
+
annotations: {
|
|
267
|
+
readOnlyHint: false,
|
|
268
|
+
destructiveHint: false,
|
|
269
|
+
// Enqueueing the same idempotency key (run_id+worker_id+type+cause_seq)
|
|
270
|
+
// never inserts a second message, so the tool is idempotent.
|
|
271
|
+
idempotentHint: true,
|
|
272
|
+
openWorldHint: false,
|
|
273
|
+
},
|
|
274
|
+
description: "Enqueue a typed, auditable message for ONE worker through the LOCAL cooperative conductor relay " +
|
|
275
|
+
"(~/.config/bridge/events.db). The supervisor sends; the worker reads/acknowledges later via check_messages. " +
|
|
276
|
+
"This is COOPERATIVE — it does NOT inject into, mutate, or prompt-inject a live worker session. " +
|
|
277
|
+
"Idempotent: a duplicate idempotency key (run_id+worker_id+type+cause_seq) does not enqueue a second message, " +
|
|
278
|
+
"and a same-type message inside the cooldown window is suppressed. Local only; does not call the Bridge API.",
|
|
279
|
+
inputSchema: {
|
|
280
|
+
run_id: z.string().min(1).describe("Run/session identifier the message is scoped to."),
|
|
281
|
+
worker_id: z.string().min(1).describe("Target worker/agent identifier."),
|
|
282
|
+
type: z.string().min(1).describe("Typed message kind (e.g. 'supervisor.worker_stalled')."),
|
|
283
|
+
cause_seq: z
|
|
284
|
+
.number()
|
|
285
|
+
.int()
|
|
286
|
+
.nonnegative()
|
|
287
|
+
.describe("Idempotency cause sequence (the supervisor's last_seq at decision time)."),
|
|
288
|
+
payload: z
|
|
289
|
+
.record(z.string(), z.unknown())
|
|
290
|
+
.optional()
|
|
291
|
+
.default({})
|
|
292
|
+
.describe("Optional compact payload. Allowed top-level keys: summary, status, message, details, reason, metrics, labels, references, payload_ref, raw."),
|
|
293
|
+
available_at: z.string().optional().describe("Optional ISO-8601 time the message becomes available (default now)."),
|
|
294
|
+
cooldown_ms: z
|
|
295
|
+
.number()
|
|
296
|
+
.int()
|
|
297
|
+
.nonnegative()
|
|
298
|
+
.optional()
|
|
299
|
+
.describe("Optional per-call cooldown override in ms (falls back to the configured cooldown)."),
|
|
300
|
+
},
|
|
301
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
302
|
+
const result = sendWorkerMessage({
|
|
303
|
+
run_id: args.run_id,
|
|
304
|
+
worker_id: args.worker_id,
|
|
305
|
+
type: args.type,
|
|
306
|
+
cause_seq: args.cause_seq,
|
|
307
|
+
payload: args.payload ?? {},
|
|
308
|
+
available_at: args.available_at,
|
|
309
|
+
cooldown_ms: args.cooldown_ms,
|
|
310
|
+
});
|
|
311
|
+
return jsonResult(result);
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
function registerCheckMessagesTool(registerTool) {
|
|
315
|
+
registerTool("check_messages", {
|
|
316
|
+
annotations: {
|
|
317
|
+
// Reads AND acknowledges (mutates pending -> acked), so not read-only and
|
|
318
|
+
// not idempotent: the first call returns+acks pending messages, later
|
|
319
|
+
// calls return none.
|
|
320
|
+
readOnlyHint: false,
|
|
321
|
+
destructiveHint: false,
|
|
322
|
+
idempotentHint: false,
|
|
323
|
+
openWorldHint: false,
|
|
324
|
+
},
|
|
325
|
+
description: "Worker checkpoint poll for the LOCAL cooperative conductor relay. Call this at natural checkpoints to read " +
|
|
326
|
+
"any supervisor messages addressed to this worker. Returned messages are ACKNOWLEDGED by this call and are " +
|
|
327
|
+
"NOT redelivered on later polls. This is cooperative polling — it is NOT live prompt injection. " +
|
|
328
|
+
"run_id/worker_id default to BAPI_CONDUCTOR_RUN_ID / BAPI_CONDUCTOR_WORKER_ID from the environment when omitted. " +
|
|
329
|
+
"Local only; does not call the Bridge API.",
|
|
330
|
+
inputSchema: {
|
|
331
|
+
run_id: z.string().optional().describe("Run identifier (defaults to BAPI_CONDUCTOR_RUN_ID)."),
|
|
332
|
+
worker_id: z.string().optional().describe("Worker identifier (defaults to BAPI_CONDUCTOR_WORKER_ID)."),
|
|
333
|
+
limit: z.number().int().positive().max(100).optional().describe("Max messages to deliver/ack (default 10, max 100)."),
|
|
334
|
+
},
|
|
335
|
+
}, withConductorToolErrorHandling(async (args) => {
|
|
336
|
+
const runId = args.run_id ?? process.env.BAPI_CONDUCTOR_RUN_ID ?? "";
|
|
337
|
+
const workerId = args.worker_id ?? process.env.BAPI_CONDUCTOR_WORKER_ID ?? "";
|
|
338
|
+
if (runId.trim().length === 0 || workerId.trim().length === 0) {
|
|
339
|
+
throw new ConductorValidationError("Conductor worker identity is unavailable: provide run_id + worker_id, or set BAPI_CONDUCTOR_RUN_ID and BAPI_CONDUCTOR_WORKER_ID.");
|
|
340
|
+
}
|
|
341
|
+
const result = checkWorkerMessages({
|
|
342
|
+
run_id: runId,
|
|
343
|
+
worker_id: workerId,
|
|
344
|
+
limit: args.limit,
|
|
345
|
+
});
|
|
346
|
+
return jsonResult(result);
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Register all conductor MCP tools through the host's `registerTool` wrapper.
|
|
351
|
+
* Keeping registration in this single module keeps `index.ts` thin. The host's
|
|
352
|
+
* `registerTool` is the SDK's heavily-generic signature, so it is accepted as
|
|
353
|
+
* `unknown` here and narrowed once to {@link RegisterToolFn} — our handlers
|
|
354
|
+
* return the same `{ content: [{ type: "text", text }] }` shape every Bridge
|
|
355
|
+
* tool uses, just expressed with a simpler local type.
|
|
356
|
+
*/
|
|
357
|
+
export function registerConductorTools(registerTool) {
|
|
358
|
+
const reg = registerTool;
|
|
359
|
+
registerEmitEventTool(reg);
|
|
360
|
+
registerPollEventsTool(reg);
|
|
361
|
+
registerWaitForEventTool(reg);
|
|
362
|
+
registerGetSupervisorSnapshotTool(reg);
|
|
363
|
+
registerGetEpicSnapshotTool(reg);
|
|
364
|
+
registerWaitForDoneGateTool(reg);
|
|
365
|
+
registerSendMessageTool(reg);
|
|
366
|
+
registerCheckMessagesTool(reg);
|
|
367
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DTO/type contracts for the conductor event ledger.
|
|
3
|
+
*
|
|
4
|
+
* These types describe the envelope a caller emits, the row stored in SQLite,
|
|
5
|
+
* the compact vs. full read projections, the query filter, and the result
|
|
6
|
+
* shapes for each ledger operation. They are pure type declarations with no
|
|
7
|
+
* runtime behavior so every conductor module can depend on them freely.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone `conductor` bin entry.
|
|
4
|
+
*
|
|
5
|
+
* Allows the conductor CLI to be invoked directly (`conductor ...`) in addition
|
|
6
|
+
* to the package subcommand form (`@bridge_gpt/mcp-server conductor ...`) routed
|
|
7
|
+
* through index.ts. Both paths share the same {@link runConductorCli} dispatcher,
|
|
8
|
+
* which is async (the foreground `supervise` command is awaited) and resolves to
|
|
9
|
+
* an exit code; this wrapper is the only place that calls `process.exit`.
|
|
10
|
+
*/
|
|
11
|
+
import { runConductorCli } from "./conductor/cli.js";
|
|
12
|
+
Promise.resolve(runConductorCli(process.argv.slice(2)))
|
|
13
|
+
.then((exitCode) => {
|
|
14
|
+
process.exit(exitCode);
|
|
15
|
+
})
|
|
16
|
+
.catch(() => {
|
|
17
|
+
// Sanitized top-level catch: never print a stack trace, raw error, or
|
|
18
|
+
// secret material. A failure here is a non-zero exit.
|
|
19
|
+
process.stderr.write("Error: conductor command failed.\n");
|
|
20
|
+
process.exit(2);
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone Claude Code → conductor lifecycle hook bin.
|
|
4
|
+
*
|
|
5
|
+
* Registered into a spawned Claude worker's `.claude/settings.local.json` by
|
|
6
|
+
* `start-tickets`. Claude Code pipes the native hook payload to this process on
|
|
7
|
+
* stdin; the runner maps it to a conductor event and shells to the `conductor`
|
|
8
|
+
* CLI. It is intentionally non-blocking: {@link runClaudeConductorHookCli}
|
|
9
|
+
* always returns `0`, and any unexpected top-level failure here also exits `0`
|
|
10
|
+
* so the Claude tool loop is never blocked by hook problems.
|
|
11
|
+
*/
|
|
12
|
+
import { runClaudeConductorHookCli } from "./conductor/claude-hook.js";
|
|
13
|
+
let exitCode = 0;
|
|
14
|
+
try {
|
|
15
|
+
exitCode = runClaudeConductorHookCli();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Defensive: never let a hook failure propagate into the Claude tool loop.
|
|
19
|
+
exitCode = 0;
|
|
20
|
+
}
|
|
21
|
+
process.exit(exitCode);
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolver for Tier-1 bridge-api credentials.
|
|
2
|
+
* Resolver and writer for Tier-1 bridge-api credentials.
|
|
3
3
|
*
|
|
4
4
|
* Resolution order is env-first, then a user-scoped credentials file:
|
|
5
5
|
* 1. `BAPI_API_KEY` in the process environment (overrides everything).
|
|
6
6
|
* 2. `$XDG_CONFIG_HOME/bridge/credentials.json` (or `~/.config/bridge/credentials.json`).
|
|
7
7
|
* 3. `~/.bridge/credentials.json` (only when the primary path is absent).
|
|
8
8
|
*
|
|
9
|
-
* The file is keyed by a logical target `bapi:<repoName>`.
|
|
10
|
-
* creates or initializes credential files
|
|
11
|
-
*
|
|
9
|
+
* The file is keyed by a logical target `bapi:<repoName>`. The resolver NEVER
|
|
10
|
+
* creates or initializes credential files. The writer ({@link upsertBapiCredential})
|
|
11
|
+
* is the ONLY mutation primitive: it upserts a single key into the user-scoped
|
|
12
|
+
* primary store via explicit install/migration code paths, preserves every
|
|
13
|
+
* other entry, and never touches project/worktree config files (those remain
|
|
14
|
+
* secret-free). No code path here places secret values in thrown errors,
|
|
15
|
+
* returned error strings, or stderr warnings.
|
|
16
|
+
*
|
|
17
|
+
* Two-runtime credential rule: the MCP server process env and a Bash-spawned
|
|
18
|
+
* CLI (e.g. `start-tickets`) env are DIFFERENT runtime surfaces. A secret in
|
|
19
|
+
* `.mcp.json`/`.cursor/mcp.json` is visible to the MCP server but NOT to a
|
|
20
|
+
* shell-spawned CLI. Any shell-spawned, credential-bearing HTTP feature must
|
|
21
|
+
* therefore resolve credentials through {@link resolveBapiCredentials} /
|
|
22
|
+
* {@link resolveCredentialBundle}, and the durable source of truth is the
|
|
23
|
+
* user-scoped `~/.config/bridge/credentials.json` store written here.
|
|
12
24
|
*/
|
|
13
25
|
import path from "path";
|
|
14
26
|
// ---------------------------------------------------------------------------
|
|
@@ -230,3 +242,162 @@ export async function resolveBapiCredentials(repoName, deps) {
|
|
|
230
242
|
credentials: { apiKey: result.values.BAPI_API_KEY, source: result.source },
|
|
231
243
|
};
|
|
232
244
|
}
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Writer (explicit install / migration paths only)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
/** Serialize credential JSON deterministically: 2-space indent + trailing newline. */
|
|
249
|
+
export function formatCredentialStoreJson(value) {
|
|
250
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Read an existing store file if present. Returns `{ state: "missing" }` on
|
|
254
|
+
* `ENOENT`, a parsed value on success, and a secret-free error otherwise. Never
|
|
255
|
+
* echoes file contents or secret values.
|
|
256
|
+
*/
|
|
257
|
+
async function readCredentialStoreJsonIfPresent(filePath, deps) {
|
|
258
|
+
let raw;
|
|
259
|
+
try {
|
|
260
|
+
raw = await deps.readFile(filePath);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
264
|
+
if (code === "ENOENT") {
|
|
265
|
+
return { state: "missing" };
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
state: "error",
|
|
269
|
+
kind: "read-error",
|
|
270
|
+
error: `Unable to read credentials file at ${filePath}.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const parsed = parseCredentialStoreJson(raw);
|
|
274
|
+
if (!parsed.ok) {
|
|
275
|
+
return {
|
|
276
|
+
state: "error",
|
|
277
|
+
kind: "parse-error",
|
|
278
|
+
error: `Invalid credentials file at ${filePath}: ${parsed.error}.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { state: "present", value: parsed.value };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* When the primary store does not yet exist, seed the new primary from the
|
|
285
|
+
* legacy fallback (`~/.bridge/credentials.json`) so its entries are not shadowed
|
|
286
|
+
* by the new primary going forward. A missing or malformed fallback yields an
|
|
287
|
+
* empty base and `migratedFallback: false` — never block or migrate broken JSON.
|
|
288
|
+
*/
|
|
289
|
+
async function mergeFallbackCredentialStoreOnFirstPrimaryWrite(deps) {
|
|
290
|
+
const fallbackPath = getFallbackCredentialStorePath(deps);
|
|
291
|
+
const fallback = await readCredentialStoreJsonIfPresent(fallbackPath, deps);
|
|
292
|
+
if (fallback.state === "present") {
|
|
293
|
+
return { base: { ...fallback.value }, migratedFallback: true };
|
|
294
|
+
}
|
|
295
|
+
return { base: {}, migratedFallback: false };
|
|
296
|
+
}
|
|
297
|
+
let tempSuffixCounter = 0;
|
|
298
|
+
function defaultTempSuffix() {
|
|
299
|
+
tempSuffixCounter += 1;
|
|
300
|
+
return `${process.pid}.${tempSuffixCounter}`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Upsert `BAPI_API_KEY` for `bapi:<repoName>` into the user-scoped PRIMARY
|
|
304
|
+
* credential store, atomically (temp file + rename) and secret-safely.
|
|
305
|
+
*
|
|
306
|
+
* - Always targets {@link getPrimaryCredentialStorePath}; never writes the
|
|
307
|
+
* fallback path and never touches project/worktree config.
|
|
308
|
+
* - Preserves every existing top-level entry and every sibling secret under
|
|
309
|
+
* `bapi:<repoName>`; replaces only `BAPI_API_KEY`.
|
|
310
|
+
* - On a first primary write, seeds from the legacy fallback store so its
|
|
311
|
+
* entries are not shadowed (`migratedFallback: true`).
|
|
312
|
+
* - POSIX: writes mode `0600` and `chmod`s the temp file before rename.
|
|
313
|
+
* `win32`: skips chmod and relies on user-profile ACLs.
|
|
314
|
+
* - Returns a structured, secret-free result. The `apiKey` value never appears
|
|
315
|
+
* in any returned error, even if an underlying I/O error message contains it.
|
|
316
|
+
*/
|
|
317
|
+
export async function upsertBapiCredential(repoName, apiKey, deps) {
|
|
318
|
+
const primaryPath = getPrimaryCredentialStorePath(deps);
|
|
319
|
+
const trimmedRepo = (repoName ?? "").trim();
|
|
320
|
+
const trimmedKey = (apiKey ?? "").trim();
|
|
321
|
+
// Derive the logical target WITHOUT lowercasing or otherwise canonicalizing.
|
|
322
|
+
const target = `bapi:${trimmedRepo}`;
|
|
323
|
+
if (trimmedRepo.length === 0) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
path: primaryPath,
|
|
327
|
+
target: "bapi:",
|
|
328
|
+
kind: "invalid-repo",
|
|
329
|
+
error: "Cannot store BAPI_API_KEY: a non-empty repo name is required.",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (trimmedKey.length === 0) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
path: primaryPath,
|
|
336
|
+
target,
|
|
337
|
+
kind: "invalid-key",
|
|
338
|
+
error: `Cannot store BAPI_API_KEY for ${target}: the provided key was empty.`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// Load the existing primary; if absent, seed from the legacy fallback store.
|
|
342
|
+
// NOTE: this load → merge → temp-write → rename sequence is NOT locked. It is
|
|
343
|
+
// safe because the writer only ever runs single-shot from the explicit
|
|
344
|
+
// install/migration paths; two genuinely concurrent upserts could last-writer-win
|
|
345
|
+
// and drop a sibling key. If a concurrent caller is ever added, introduce a lock.
|
|
346
|
+
const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
|
|
347
|
+
let base;
|
|
348
|
+
let migratedFallback = false;
|
|
349
|
+
if (primary.state === "error") {
|
|
350
|
+
return { ok: false, path: primaryPath, target, kind: primary.kind, error: primary.error };
|
|
351
|
+
}
|
|
352
|
+
if (primary.state === "present") {
|
|
353
|
+
base = { ...primary.value };
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
const seeded = await mergeFallbackCredentialStoreOnFirstPrimaryWrite(deps);
|
|
357
|
+
base = seeded.base;
|
|
358
|
+
migratedFallback = seeded.migratedFallback;
|
|
359
|
+
}
|
|
360
|
+
const existingEntry = base[target];
|
|
361
|
+
const hadKey = !!existingEntry &&
|
|
362
|
+
typeof existingEntry.BAPI_API_KEY === "string" &&
|
|
363
|
+
existingEntry.BAPI_API_KEY.length > 0;
|
|
364
|
+
const action = hadKey ? "updated" : "created";
|
|
365
|
+
// Preserve sibling secret names; replace ONLY BAPI_API_KEY.
|
|
366
|
+
const nextEntry = { ...(existingEntry ?? {}), BAPI_API_KEY: trimmedKey };
|
|
367
|
+
const next = { ...base, [target]: nextEntry };
|
|
368
|
+
const dir = path.dirname(primaryPath);
|
|
369
|
+
const suffix = (deps.tempSuffix ?? defaultTempSuffix)();
|
|
370
|
+
const tempPath = path.join(dir, `${path.basename(primaryPath)}.${suffix}.tmp`);
|
|
371
|
+
const json = formatCredentialStoreJson(next);
|
|
372
|
+
const isPosix = deps.platform !== "win32";
|
|
373
|
+
try {
|
|
374
|
+
await deps.mkdir(dir, { recursive: true });
|
|
375
|
+
const writeOptions = isPosix
|
|
376
|
+
? { encoding: "utf-8", mode: 0o600 }
|
|
377
|
+
: { encoding: "utf-8" };
|
|
378
|
+
await deps.writeFile(tempPath, json, writeOptions);
|
|
379
|
+
if (isPosix) {
|
|
380
|
+
await deps.chmod(tempPath, 0o600);
|
|
381
|
+
}
|
|
382
|
+
await deps.rename(tempPath, primaryPath);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// Best-effort temp cleanup; never surface the secret or the raw I/O message.
|
|
386
|
+
if (deps.unlink) {
|
|
387
|
+
try {
|
|
388
|
+
await deps.unlink(tempPath);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
/* cleanup failure must not mask the primary write error */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
path: primaryPath,
|
|
397
|
+
target,
|
|
398
|
+
kind: "write-error",
|
|
399
|
+
error: `Failed to write credentials file at ${primaryPath}.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return { ok: true, path: primaryPath, target, action, migratedFallback };
|
|
403
|
+
}
|