@miraland-labs/conduit-bridge 0.1.0

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/dist/driver.js ADDED
@@ -0,0 +1,201 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
6
+ /** Grants → Claude Code tool allow-list. Privileged grants are never mapped. */
7
+ export function claudeToolsForGrants(grants, verificationCommands = []) {
8
+ const tools = [];
9
+ if (grants.includes("repo_read"))
10
+ tools.push("Read", "Glob", "Grep");
11
+ if (grants.includes("repo_write"))
12
+ tools.push("Edit", "Write");
13
+ if (grants.includes("test_run")) {
14
+ tools.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
15
+ }
16
+ if (grants.includes("branch_create")) {
17
+ tools.push("Bash(git status:*)", "Bash(git diff:*)", "Bash(git add:*)", "Bash(git commit:*)", "Bash(git branch:*)", "Bash(git switch:*)");
18
+ }
19
+ if (grants.includes("pr_create"))
20
+ tools.push("Bash(git push:*)", "Bash(gh pr create:*)");
21
+ return tools;
22
+ }
23
+ export const claudeDeniedTools = [
24
+ "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git reset --hard:*)", "Bash(git push --force:*)",
25
+ "Bash(npm run deploy:*)", "Bash(pnpm deploy:*)", "Bash(yarn deploy:*)", "Bash(wrangler deploy:*)",
26
+ "Bash(kubectl:*)", "Bash(terraform apply:*)",
27
+ ];
28
+ function isBoundedVerificationCommand(command) {
29
+ return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check))$/.test(command);
30
+ }
31
+ export function buildAssignmentPrompt(context) {
32
+ const packageContext = context.workPackage;
33
+ const spec = context.spec;
34
+ const role = packageContext?.work_role ?? spec.work_role ?? "implement";
35
+ const goal = packageContext?.goal ?? spec.goal ?? context.objective;
36
+ const scope = packageContext?.scope?.length ? packageContext.scope : spec.scope;
37
+ const boundaries = packageContext?.boundaries?.length ? packageContext.boundaries : spec.boundaries;
38
+ const acceptance = packageContext?.acceptance?.length ? packageContext.acceptance : spec.acceptance;
39
+ const changeScope = packageContext?.change_scope?.length ? packageContext.change_scope : spec.change_scope;
40
+ const evidence = packageContext?.required_evidence?.length ? packageContext.required_evidence : spec.required_evidence;
41
+ const rework = packageContext?.rework_feedback ?? context.reworkFeedback;
42
+ const lines = [
43
+ `You are completing one delegated Conduit assignment as the ${role} role. Work only inside the current workspace.`,
44
+ "",
45
+ `GOAL\n${goal}`,
46
+ ];
47
+ if (packageContext?.initiative?.title || packageContext?.initiative?.desired_outcome) {
48
+ lines.push("", "INITIATIVE CONTEXT");
49
+ if (packageContext.initiative.title)
50
+ lines.push(`- Title: ${packageContext.initiative.title}`);
51
+ if (packageContext.initiative.desired_outcome)
52
+ lines.push(`- Desired outcome: ${packageContext.initiative.desired_outcome}`);
53
+ }
54
+ if (scope?.length)
55
+ lines.push("", `SCOPE\n${scope.map((item) => `- ${item}`).join("\n")}`);
56
+ if (boundaries?.length)
57
+ lines.push("", `BOUNDARIES — never violate these\n${boundaries.map((item) => `- ${item}`).join("\n")}`);
58
+ if (acceptance?.length)
59
+ lines.push("", `ACCEPTANCE CRITERIA — the delivery is judged against these\n${acceptance.map((item) => `- ${item}`).join("\n")}`);
60
+ if (evidence?.length)
61
+ lines.push("", `REQUIRED EVIDENCE\n${evidence.map((item) => `- ${item}`).join("\n")}`);
62
+ if (changeScope?.length)
63
+ lines.push("", `CHANGE SCOPE — only modify paths under\n${changeScope.map((item) => `- ${item}`).join("\n")}`);
64
+ if (spec.repository?.base_commit)
65
+ lines.push("", `The workspace is expected to be at or after base commit ${spec.repository.base_commit}.`);
66
+ if (context.currentHead)
67
+ lines.push(`The workspace is currently at commit ${context.currentHead}; treat that as the true current state.`);
68
+ if (packageContext?.decisions?.length) {
69
+ lines.push("", "PROJECT DECISIONS (explicit human decisions — treat as binding context)", ...packageContext.decisions.map((item) => `- Q: ${item.question}\n A: ${item.decision}`));
70
+ }
71
+ if (rework)
72
+ lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
73
+ lines.push("", "RULES", "- Stay within the change scope and boundaries.", "- Run the relevant verification commands if your permissions allow it, and report their real results.", "- Never merge, deploy, push to protected branches, or touch production.", "- Do not invent evidence. Report unknown when you could not verify a criterion.", "", "When the work is finished, end your reply with exactly one fenced ```json block:", '{"outcome": "one-paragraph summary", "changes": ["path — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "exact criterion text", "status": "met|not_met|unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["exact criterion text supported by this evidence"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}');
74
+ return lines.join("\n");
75
+ }
76
+ /** Parse the agent's final fenced JSON block into a bounded report. */
77
+ export function parseAgentReport(text, acceptance) {
78
+ const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/g)];
79
+ const last = blocks.at(-1)?.[1];
80
+ if (!last)
81
+ throw new Error("Agent did not emit the required structured report");
82
+ let raw;
83
+ try {
84
+ raw = JSON.parse(last);
85
+ }
86
+ catch {
87
+ throw new Error("Agent emitted malformed report JSON");
88
+ }
89
+ const stringList = z.array(z.string().trim().min(1).max(4_000)).max(100);
90
+ const parsed = z.object({
91
+ outcome: z.string().trim().min(1).max(20_000),
92
+ changes: stringList,
93
+ verification: stringList,
94
+ acceptance_results: z.array(z.object({ criterion: z.string().trim().min(1).max(4_000), status: z.enum(["met", "not_met", "unknown"]) })).max(100),
95
+ evidence: z.array(z.object({
96
+ kind: z.enum(evidenceKinds), name: z.string().trim().min(1).max(500), uri: z.string().url().max(4_000).optional(),
97
+ digest: z.string().trim().min(1).max(500).optional(), details: stringList, acceptance_criteria: stringList,
98
+ })).max(100),
99
+ assumptions: stringList, risks: stringList, limitations: stringList,
100
+ head_commit: z.string().regex(/^[0-9a-f]{7,64}$/i).optional(),
101
+ pull_request_url: z.string().url().max(4_000).optional(),
102
+ }).strict().safeParse(raw);
103
+ if (!parsed.success)
104
+ throw new Error(`Agent report is invalid: ${parsed.error.issues[0]?.message ?? "unknown validation error"}`);
105
+ const reported = parsed.data.acceptance_results.map((item) => item.criterion);
106
+ if (new Set(reported).size !== reported.length || acceptance.some((criterion) => !reported.includes(criterion))) {
107
+ throw new Error("Agent report must include every acceptance criterion exactly once");
108
+ }
109
+ return { ...parsed.data, acceptance_results: parsed.data.acceptance_results.map((item) => ({ ...item, evidence_artifact_ids: [] })) };
110
+ }
111
+ export const claudeCodeDriver = {
112
+ name: "claude-code",
113
+ async run(input) {
114
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
115
+ if (fuelSource === "local" && !hasLocalVendorLogin()) {
116
+ return {
117
+ status: "failed",
118
+ resultText: null,
119
+ sessionId: null,
120
+ error: "machine set to local fuel but claude-code has no login",
121
+ };
122
+ }
123
+ const tools = claudeToolsForGrants(input.grants, input.verificationCommands);
124
+ // Fail closed: omitting --allowedTools would leave only the deny-list and
125
+ // broaden the agent beyond the grant contract (privileged grants map to none).
126
+ if (!tools.length) {
127
+ return {
128
+ status: "failed",
129
+ resultText: null,
130
+ sessionId: null,
131
+ error: "No Bridge-mapped tools for active grants; refusing to start agent without an allow-list",
132
+ };
133
+ }
134
+ const args = ["-p", input.prompt, "--output-format", "json"];
135
+ if (input.resumeSessionId)
136
+ args.push("--resume", input.resumeSessionId);
137
+ args.push("--allowedTools", tools.join(","));
138
+ args.push("--disallowedTools", claudeDeniedTools.join(","));
139
+ if (input.grants.includes("repo_write"))
140
+ args.push("--permission-mode", "acceptEdits");
141
+ const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
142
+ let message = null;
143
+ try {
144
+ message = JSON.parse(stdout);
145
+ }
146
+ catch {
147
+ message = null;
148
+ }
149
+ const sessionId = message?.session_id ?? null;
150
+ if (code !== 0 || !message || message.is_error) {
151
+ return { status: "failed", resultText: message?.result ?? null, sessionId, error: (message?.result || stderr || `agent exited with code ${code}`).slice(0, 20_000) };
152
+ }
153
+ return { status: "completed", resultText: message.result ?? "", sessionId };
154
+ },
155
+ };
156
+ export const DRIVERS = { "claude-code": claudeCodeDriver };
157
+ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit") {
158
+ return new Promise((resolve, reject) => {
159
+ const child = spawn(executable, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: boundedEnvironment(fuel, fuelSource) });
160
+ let stdout = "";
161
+ let stderr = "";
162
+ const timer = setTimeout(() => { child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 10_000).unref(); }, timeoutMs);
163
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
164
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
165
+ child.on("error", (error) => { clearTimeout(timer); reject(error); });
166
+ child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr: stderr.slice(0, 20_000) }); });
167
+ });
168
+ }
169
+ const LOCAL_VENDOR_ENV = [
170
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
171
+ "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE",
172
+ ];
173
+ function boundedEnvironment(fuel, fuelSource = "conduit") {
174
+ // Proxy and TLS variables stay: fueled agents must reach Conduit's /v1 on
175
+ // machines that only have network access through a proxy.
176
+ const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TERM", "NO_COLOR", "FORCE_COLOR",
177
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS",
178
+ ...(fuelSource === "local" ? LOCAL_VENDOR_ENV : [])];
179
+ const env = Object.fromEntries(allowed.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
180
+ if (fuelSource === "conduit" && fuel) {
181
+ const v1 = `${fuel.baseUrl.replace(/\/+$/, "")}/v1`;
182
+ env.ANTHROPIC_API_KEY = fuel.gatewayKey;
183
+ env.ANTHROPIC_BASE_URL = v1;
184
+ env.OPENAI_API_KEY = fuel.gatewayKey;
185
+ env.OPENAI_BASE_URL = v1;
186
+ }
187
+ return env;
188
+ }
189
+ /** True when local fuel can use a host vendor login (env key or Claude credentials dir). */
190
+ export function hasLocalVendorLogin(env = process.env) {
191
+ if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY)
192
+ return true;
193
+ const home = env.HOME;
194
+ if (!home)
195
+ return false;
196
+ return existsSync(join(home, ".claude"));
197
+ }
198
+ /** Exported for tests — builds the stripped process env with optional Conduit fuel. */
199
+ export function agentProcessEnv(fuel, fuelSource = "conduit") {
200
+ return boundedEnvironment(fuel, fuelSource);
201
+ }
@@ -0,0 +1,264 @@
1
+ import { z } from "zod";
2
+ import { ConduitRequestError } from "./client.js";
3
+ import { redactSecrets } from "./config.js";
4
+ import { buildAssignmentPrompt, evidenceKinds, parseAgentReport } from "./driver.js";
5
+ import { buildWorkspaceBrief } from "./brief.js";
6
+ const assignmentSchema = z.object({
7
+ id: z.string().uuid(),
8
+ attempt_id: z.string().uuid(),
9
+ execution_mode: z.enum(["agent", "human"]).optional().default("agent"),
10
+ });
11
+ const taskDetailSchema = z.object({
12
+ objective: z.string(),
13
+ project_id: z.string().uuid(),
14
+ spec_json: z.string().nullable(),
15
+ grants_json: z.string().nullable(),
16
+ delivery_state: z.string(),
17
+ delivery_summary: z.string().nullable(),
18
+ execution_mode: z.enum(["agent", "human"]).optional().default("agent"),
19
+ });
20
+ const taskSpecSchema = z.object({
21
+ goal: z.string().optional(), scope: z.array(z.string()).optional(), boundaries: z.array(z.string()).optional(),
22
+ acceptance: z.array(z.string()).optional(), required_evidence: z.array(z.enum(evidenceKinds)).optional(),
23
+ change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
24
+ repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
25
+ risk_level: z.string().optional(),
26
+ });
27
+ const workPackageSchema = z.object({
28
+ work_role: z.string().nullable().optional(),
29
+ goal: z.string().nullable().optional(),
30
+ scope: z.array(z.string()).optional(),
31
+ boundaries: z.array(z.string()).optional(),
32
+ acceptance: z.array(z.string()).optional(),
33
+ change_scope: z.array(z.string()).optional(),
34
+ required_evidence: z.array(z.string()).optional(),
35
+ initiative: z.object({
36
+ title: z.string().nullable().optional(),
37
+ desired_outcome: z.string().nullable().optional(),
38
+ boundaries: z.array(z.string()).optional(),
39
+ }).optional(),
40
+ decisions: z.array(z.object({ question: z.string(), decision: z.string() })).optional(),
41
+ rework_feedback: z.string().nullable().optional(),
42
+ }).nullable().optional();
43
+ export async function renewLeases(client, config) {
44
+ for (const active of Object.values(config.activeAttempts)) {
45
+ if (Date.parse(active.leaseExpiresAt) - Date.now() < 120_000) {
46
+ try {
47
+ const response = await client.attemptRequest(active.taskId, "lease", { idempotency_key: `bridge:lease:${active.attemptId}:${Math.floor(Date.now() / 60_000)}` });
48
+ await client.updateAttempt(active.taskId, { leaseExpiresAt: String(response.lease_expires_at) });
49
+ }
50
+ catch (error) {
51
+ if (error instanceof ConduitRequestError && error.code === "invalid_lease")
52
+ await client.clearAttempt(active.taskId);
53
+ else
54
+ throw error;
55
+ }
56
+ }
57
+ }
58
+ }
59
+ export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs) {
60
+ const active = Object.values(config.activeAttempts)[0];
61
+ if (!active)
62
+ return false;
63
+ if (active.phase === "agent_running") {
64
+ await queueTerminal(client, active.taskId, {
65
+ action: "fail",
66
+ body: { error: "Bridge restarted during agent execution; Conduit may retry this work safely.", idempotency_key: `bridge:restart:${active.attemptId}` },
67
+ });
68
+ return true;
69
+ }
70
+ if (client.attempt(active.taskId).phase === "agent_finished") {
71
+ await submitFinishedDelivery(client, active.taskId);
72
+ return true;
73
+ }
74
+ if (client.attempt(active.taskId).phase === "terminal_pending") {
75
+ await flushTerminal(client, active.taskId);
76
+ return true;
77
+ }
78
+ await runClaimedAssignment(client, config, driver, workspace, brief, active.taskId, timeoutMs);
79
+ return true;
80
+ }
81
+ export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs) {
82
+ if (Object.keys(config.activeAttempts).length)
83
+ return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs);
84
+ const data = await client.request("/runner/v1/assignments");
85
+ const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
86
+ const assignment = assignments[0];
87
+ if (!assignment)
88
+ return false;
89
+ if (assignment.execution_mode === "human") {
90
+ console.log(`Human takeover assignment ${assignment.id} — use Bridge MCP tools to claim and submit (agent runner skips).`);
91
+ return false;
92
+ }
93
+ console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
94
+ await client.claim(assignment.id, assignment.attempt_id);
95
+ await runClaimedAssignment(client, config, driver, workspace, brief, assignment.id, timeoutMs);
96
+ return true;
97
+ }
98
+ async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs) {
99
+ const active = client.attempt(taskId);
100
+ const detail = await client.request(`/runner/v1/tasks/${taskId}`);
101
+ const task = taskDetailSchema.parse(detail.task);
102
+ const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
103
+ const spec = parseTaskSpec(task.spec_json);
104
+ const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
105
+ const reworkFeedback = task.delivery_state === "changes_requested" && task.delivery_summary && !task.delivery_summary.startsWith("{") ? task.delivery_summary : null;
106
+ // Recompile current state at claim time — earlier packages may have moved the repo.
107
+ const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
108
+ const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace, currentHead: liveBrief?.base_commit ?? null, reworkFeedback, workPackage });
109
+ await client.attemptRequest(taskId, "progress", { phase: "changing", message: `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`, idempotency_key: `bridge:progress:${active.attemptId}:start` });
110
+ const fuelSource = config.fuelSource === "local" ? "local" : "conduit";
111
+ await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource });
112
+ let fuel;
113
+ if (fuelSource === "conduit") {
114
+ const gatewayKey = await client.ensureFuel(task.project_id);
115
+ fuel = { baseUrl: config.baseUrl, gatewayKey };
116
+ }
117
+ const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
118
+ try {
119
+ const result = await driver.run({
120
+ prompt,
121
+ workspace,
122
+ grants,
123
+ verificationCommands: liveBrief?.verification ?? [],
124
+ resumeSessionId: config.sessions?.[taskId],
125
+ timeoutMs,
126
+ fuel,
127
+ fuelSource,
128
+ });
129
+ if (result.sessionId)
130
+ config.sessions = { ...config.sessions, [taskId]: result.sessionId };
131
+ if (result.status === "failed") {
132
+ await queueTerminal(client, taskId, { action: "fail", body: { error: result.error ?? "Agent execution failed", idempotency_key: `bridge:fail:${active.attemptId}` } });
133
+ console.error(`Assignment ${taskId} failed: ${redactSecrets(result.error ?? "unknown")}`);
134
+ return;
135
+ }
136
+ let report;
137
+ try {
138
+ report = parseAgentReport(result.resultText ?? "", spec.acceptance ?? []);
139
+ validateDeliveryReport(report, spec);
140
+ }
141
+ catch (error) {
142
+ const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
143
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: false, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
144
+ console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(message)}`);
145
+ return;
146
+ }
147
+ await client.updateAttempt(taskId, { phase: "agent_finished", delivery: { spec, report } });
148
+ await submitFinishedDelivery(client, taskId);
149
+ console.log(`Assignment ${taskId} delivered for review and acceptance.`);
150
+ }
151
+ finally {
152
+ clearInterval(renewTimer);
153
+ }
154
+ }
155
+ async function submitFinishedDelivery(client, taskId) {
156
+ const active = client.attempt(taskId);
157
+ if (!active.delivery)
158
+ throw new Error("Finished agent run is missing its persisted Delivery data");
159
+ const terminal = await prepareDelivery(client, active.attemptId, taskId, active.delivery.report);
160
+ await client.attemptRequest(taskId, "progress", { phase: "preparing_delivery", message: "Agent finished; submitting the Delivery.", idempotency_key: `bridge:progress:${active.attemptId}:delivery` });
161
+ await queueTerminal(client, taskId, terminal);
162
+ }
163
+ function parseTaskSpec(value) {
164
+ if (!value)
165
+ return {};
166
+ try {
167
+ return taskSpecSchema.parse(JSON.parse(value));
168
+ }
169
+ catch {
170
+ throw new Error("Assigned task contains an invalid execution contract");
171
+ }
172
+ }
173
+ async function prepareDelivery(client, attemptId, taskId, report) {
174
+ const fuelMode = client.attempt(taskId).fuelMode === "local" ? "local" : "conduit";
175
+ const evidence = [];
176
+ const evidenceByCriterion = new Map();
177
+ for (const [index, item] of report.evidence.entries()) {
178
+ const uri = item.uri ?? `urn:conduit:attempt:${attemptId}:evidence:${index + 1}`;
179
+ const response = await client.attemptRequest(taskId, "artifacts", {
180
+ kind: item.kind, name: item.name, uri, digest: item.digest,
181
+ metadata: { details: item.details, acceptance_criteria: item.acceptance_criteria },
182
+ idempotency_key: `bridge:artifact:${attemptId}:${index + 1}`,
183
+ });
184
+ const artifactId = z.string().uuid().parse(response.artifact_id);
185
+ evidence.push({ artifact_id: artifactId, kind: item.kind });
186
+ for (const criterion of item.acceptance_criteria)
187
+ evidenceByCriterion.set(criterion, [...(evidenceByCriterion.get(criterion) ?? []), artifactId]);
188
+ }
189
+ const acceptanceResults = report.acceptance_results.map((result) => ({ ...result, evidence_artifact_ids: evidenceByCriterion.get(result.criterion) ?? [] }));
190
+ const { head_commit, pull_request_url, evidence: _evidence, ...body } = report;
191
+ const references = [
192
+ ...(pull_request_url ? [{ kind: "pull_request", uri: pull_request_url }] : []),
193
+ ...report.evidence.flatMap((item) => item.uri ? [{ kind: referenceKind(item.kind), uri: item.uri }] : []),
194
+ ];
195
+ return {
196
+ action: "complete",
197
+ body: {
198
+ delivery_packet: { ...body, acceptance_results: acceptanceResults, ...(head_commit ? { head_commit } : {}), ...(pull_request_url ? { pull_request_url } : {}), references, fuel_mode: fuelMode },
199
+ evidence,
200
+ idempotency_key: `bridge:complete:${attemptId}`,
201
+ },
202
+ };
203
+ }
204
+ export function validateDeliveryReport(report, spec) {
205
+ validateChangeScope(report, spec.change_scope ?? []);
206
+ const supplied = new Set(report.evidence.map((item) => item.kind));
207
+ const missing = (spec.required_evidence ?? []).filter((kind) => !supplied.has(kind));
208
+ if (missing.length)
209
+ throw new Error(`Agent report is missing required evidence: ${missing.join(", ")}`);
210
+ const unsupported = report.acceptance_results
211
+ .filter((result) => result.status === "met" && !report.evidence.some((item) => item.acceptance_criteria.includes(result.criterion)))
212
+ .map((result) => result.criterion);
213
+ if (unsupported.length)
214
+ throw new Error(`Met acceptance criteria require mapped evidence: ${unsupported.join("; ")}`);
215
+ }
216
+ function referenceKind(kind) {
217
+ if (kind === "change")
218
+ return "commit";
219
+ if (kind === "test")
220
+ return "ci";
221
+ if (kind === "preview")
222
+ return "preview";
223
+ if (kind === "documentation")
224
+ return "document";
225
+ return "other";
226
+ }
227
+ export function validateChangeScope(report, changeScope) {
228
+ if (!changeScope.length)
229
+ return;
230
+ if (!report.head_commit)
231
+ throw new Error("Repository changes require a delivered head commit");
232
+ const paths = report.changes.map((change) => change.split(/\s+[—-]\s+/, 1)[0].trim().replaceAll("\\", "/"));
233
+ const outside = paths.filter((path) => !path || path.startsWith("/") || path.split("/").includes("..") || !changeScope.some((scope) => pathMatchesScope(path, scope)));
234
+ if (outside.length)
235
+ throw new Error(`Agent reported changes outside the approved scope: ${outside.join(", ")}`);
236
+ }
237
+ function pathMatchesScope(path, scope) {
238
+ const normalized = scope.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
239
+ if (normalized.endsWith("/**")) {
240
+ const prefix = normalized.slice(0, -3);
241
+ return path === prefix || path.startsWith(`${prefix}/`);
242
+ }
243
+ return path === normalized;
244
+ }
245
+ async function queueTerminal(client, taskId, terminal) {
246
+ await client.updateAttempt(taskId, { phase: "terminal_pending", terminal });
247
+ await flushTerminal(client, taskId);
248
+ }
249
+ export async function flushTerminal(client, taskId) {
250
+ const active = client.attempt(taskId);
251
+ if (!active.terminal)
252
+ throw new Error("Pending terminal operation is missing its replay payload");
253
+ try {
254
+ await client.attemptRequest(taskId, active.terminal.action, active.terminal.body);
255
+ await client.clearAttempt(taskId);
256
+ }
257
+ catch (error) {
258
+ if (error instanceof ConduitRequestError && error.code === "invalid_lease") {
259
+ await client.clearAttempt(taskId);
260
+ return;
261
+ }
262
+ throw error;
263
+ }
264
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,54 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { ConduitClient } from "./client.js";
5
+ import { loadConfig } from "./config.js";
6
+ const result = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
7
+ const executionPhaseSchema = z.enum(["inspecting", "changing", "verifying", "preparing_delivery"]);
8
+ /** Keep aligned with src/conductor/delivery.ts deliveryPacketSchema. */
9
+ export const deliveryPacketSchema = z.object({
10
+ outcome: z.string().trim().min(1).max(20_000),
11
+ head_commit: z.string().regex(/^[0-9a-f]{7,64}$/i, "Head commit must be a Git commit SHA").optional(),
12
+ pull_request_url: z.string().url().max(4_000).optional(),
13
+ changes: z.array(z.string().trim().min(1).max(4_000)).max(100),
14
+ acceptance_results: z.array(z.object({
15
+ criterion: z.string().min(1).max(4_000),
16
+ status: z.enum(["met", "not_met", "unknown"]),
17
+ evidence_artifact_ids: z.array(z.string().uuid()).max(100),
18
+ })).max(100),
19
+ verification: z.array(z.string().trim().min(1).max(4_000)).max(100),
20
+ assumptions: z.array(z.string().trim().min(1).max(4_000)).max(100),
21
+ risks: z.array(z.string().trim().min(1).max(4_000)).max(100),
22
+ limitations: z.array(z.string().trim().min(1).max(4_000)).max(100),
23
+ references: z.array(z.object({
24
+ kind: z.enum(["commit", "pull_request", "ci", "preview", "document", "other"]),
25
+ uri: z.string().url().max(4_000),
26
+ })).max(100),
27
+ });
28
+ export const BRIDGE_MCP_TOOLS = [
29
+ "list_assignments",
30
+ "get_assignment",
31
+ "claim_assignment",
32
+ "report_progress",
33
+ "raise_question",
34
+ "add_artifact",
35
+ "submit_delivery",
36
+ "fail_assignment",
37
+ ];
38
+ export function createBridgeMcpServer(client) {
39
+ const server = new McpServer({ name: "conduit", version: "0.1.0" });
40
+ server.registerTool("list_assignments", { description: "List work assigned to this machine" }, async () => result(await client.request("/runner/v1/assignments")));
41
+ server.registerTool("get_assignment", { description: "Get one assigned task", inputSchema: { task_id: z.string().uuid() } }, async ({ task_id }) => result(await client.request(`/runner/v1/tasks/${task_id}`)));
42
+ server.registerTool("claim_assignment", { description: "Claim an assignment and store its lease", inputSchema: { task_id: z.string().uuid(), attempt_id: z.string().uuid() } }, async ({ task_id, attempt_id }) => result(await client.claim(task_id, attempt_id)));
43
+ server.registerTool("report_progress", { description: "Report the current execution phase and concise observable progress", inputSchema: { task_id: z.string().uuid(), phase: executionPhaseSchema, message: z.string().min(1), idempotency_key: z.string().min(1) } }, async (input) => result(await client.attemptRequest(input.task_id, "progress", { phase: input.phase, message: input.message, idempotency_key: input.idempotency_key })));
44
+ server.registerTool("raise_question", { description: "Raise a structured blocker for human judgment", inputSchema: { task_id: z.string().uuid(), question: z.string().min(1), impact: z.string().min(1), options: z.array(z.string()).default([]), recommendation: z.string().optional(), idempotency_key: z.string().min(1) } }, async (input) => result(await client.attemptRequest(input.task_id, "blockers", input)));
45
+ server.registerTool("add_artifact", { description: "Attach external delivery evidence metadata", inputSchema: { task_id: z.string().uuid(), kind: z.string().min(1), name: z.string().min(1), uri: z.string().url(), digest: z.string().optional(), metadata: z.record(z.unknown()).optional(), idempotency_key: z.string().min(1) } }, async (input) => result(await client.attemptRequest(input.task_id, "artifacts", input)));
46
+ server.registerTool("submit_delivery", { description: "Submit a structured Delivery Packet for human acceptance", inputSchema: { task_id: z.string().uuid(), packet: deliveryPacketSchema, evidence: z.array(z.object({ artifact_id: z.string().uuid(), kind: z.string() })).default([]), idempotency_key: z.string().min(1) } }, async (input) => result(await client.attemptRequest(input.task_id, "complete", { delivery_packet: input.packet, evidence: input.evidence, idempotency_key: input.idempotency_key })));
47
+ server.registerTool("fail_assignment", { description: "Report a terminal failed attempt", inputSchema: { task_id: z.string().uuid(), error: z.string().min(1), idempotency_key: z.string().min(1) } }, async (input) => result(await client.attemptRequest(input.task_id, "fail", input)));
48
+ return server;
49
+ }
50
+ export async function runMcp() {
51
+ const client = new ConduitClient(await loadConfig());
52
+ const server = createBridgeMcpServer(client);
53
+ await server.connect(new StdioServerTransport());
54
+ }