@miraland-labs/conduit-bridge 0.11.10 → 0.11.12

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/brief.js CHANGED
@@ -8,7 +8,8 @@ const MANIFESTS = [
8
8
  "Cargo.toml", "pyproject.toml", "pytest.ini", "setup.cfg", "go.mod",
9
9
  "Makefile", "build.gradle", "build.gradle.kts", "gradlew",
10
10
  ];
11
- const VERIFICATION_SCRIPTS = ["verify", "typecheck", "lint", "test", "build"];
11
+ /** Prefer test before typecheck/lint so discovery order matches what pickVerificationCommand wants. */
12
+ const VERIFICATION_SCRIPTS = ["test", "verify", "typecheck", "lint", "build"];
12
13
  const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage", ".venv", "venv"]);
13
14
  export async function buildWorkspaceBrief(workspace) {
14
15
  const entries = await readdir(workspace, { withFileTypes: true });
@@ -208,7 +209,7 @@ export async function discoverVerificationCommands(workspace, files) {
208
209
  if (files.has("Makefile")) {
209
210
  try {
210
211
  const makefile = await readFile(join(workspace, "Makefile"), "utf8");
211
- for (const target of ["check", "test"])
212
+ for (const target of ["test", "check"])
212
213
  if (new RegExp(`^${target}\\s*:`, "m").test(makefile))
213
214
  commands.push(`make ${target}`);
214
215
  }
package/dist/driver.js CHANGED
@@ -225,6 +225,9 @@ function sanitizeReportShape(raw) {
225
225
  // Drop non-URL / empty uris so an optional bad uri doesn't fail the whole report.
226
226
  if ("uri" in evidence && (typeof evidence.uri !== "string" || !z.string().url().max(4_000).safeParse(evidence.uri).success))
227
227
  delete evidence.uri;
228
+ // Witness is Bridge-authored provenance. An agent claiming it must not survive parse.
229
+ if ("witness" in evidence)
230
+ delete evidence.witness;
228
231
  }
229
232
  }
230
233
  // Same rule for optional top-level scalars: null/"" means absent, not invalid.
@@ -1,30 +1,31 @@
1
1
  /**
2
- * When the contract requires test evidence and the agent forgot to paste command output,
3
- * Bridge runs one bounded verification command and attaches the verbatim result — same
4
- * mechanical-land pattern as ensureDeliveryPullRequest.
2
+ * When the contract requires test evidence, Bridge runs one bounded verification
3
+ * command and attaches the verbatim result. Agent prose may supplement that run;
4
+ * it never substitutes for it.
5
5
  */
6
6
  import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { isBoundedVerificationCommand } from "./execution-class.js";
9
9
  const execFileAsync = promisify(execFile);
10
10
  const TEST_EVIDENCE_DETAILS_MIN = 32;
11
- export function needsTestEvidence(report, spec, grants) {
11
+ /** Contract requires test + test_run — Bridge always runs; agent paste is not a substitute. */
12
+ export function needsTestEvidence(_report, spec, grants) {
12
13
  if (!grants.includes("test_run"))
13
14
  return false;
14
- if (!(spec.required_evidence ?? []).includes("test"))
15
- return false;
16
- const existing = report.evidence.filter((item) => item.kind === "test");
17
- if (existing.length === 0)
18
- return true;
19
- return existing.every((item) => item.details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN);
15
+ return (spec.required_evidence ?? []).includes("test");
16
+ }
17
+ /**
18
+ * Prefer real test runners over typecheck/lint/build/verify so `required_evidence: ["test"]`
19
+ * is not satisfied by a silent `npm run typecheck`.
20
+ */
21
+ export function isPreferentialTestCommand(command) {
22
+ return /^(npm run test|pnpm test|yarn test|cargo test|go test(?: \.\/\.\.\.)?|make test|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command.trim());
20
23
  }
21
24
  export function pickVerificationCommand(commands) {
22
- for (const command of commands) {
23
- const trimmed = command.trim();
24
- if (trimmed && isBoundedVerificationCommand(trimmed))
25
- return trimmed;
26
- }
27
- return null;
25
+ const bounded = commands
26
+ .map((command) => command.trim())
27
+ .filter((command) => command && isBoundedVerificationCommand(command));
28
+ return bounded.find(isPreferentialTestCommand) ?? bounded[0] ?? null;
28
29
  }
29
30
  function argvForBoundedCommand(command) {
30
31
  return command.trim().split(/\s+/);
@@ -70,8 +71,8 @@ export function criteriaForTestEvidence(report) {
70
71
  return [...mapped];
71
72
  }
72
73
  /**
73
- * If test evidence is required and missing/thin, run one bounded verification command
74
- * in the attempt workspace and attach verbatim stdout/stderr.
74
+ * If test evidence is required, run one bounded verification command in the attempt workspace
75
+ * and attach verbatim stdout/stderr with a Bridge witness marker.
75
76
  */
76
77
  export async function ensureTestEvidence(input) {
77
78
  if (!needsTestEvidence(input.report, input.spec, input.grants))
@@ -82,26 +83,32 @@ export async function ensureTestEvidence(input) {
82
83
  }
83
84
  const run = input.runCommand ?? defaultRunCommand;
84
85
  const result = await run(command, input.workspace);
85
- // Silent success is valid for bounded targets like `make test` (`@test -f …` prints nothing).
86
- // Still record an explicit no-output line so control-plane verbatim-detail checks pass.
87
86
  const stdoutLines = result.stdout.trim() ? result.stdout.trim().split("\n") : [];
88
87
  const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
88
+ if (result.code !== 0) {
89
+ // Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
90
+ const failedDetails = [
91
+ `$ ${command}`,
92
+ ...stdoutLines,
93
+ ...stderrLines,
94
+ `exit ${result.code}`,
95
+ ].map((line) => line.slice(0, 4_000)).slice(0, 100);
96
+ throw new Error(`Agent report: Verification failed (${command}): ${failedDetails.join("\n").slice(0, 2_000)}`);
97
+ }
98
+ // Empty exit-0 is not proof — a no-op recipe (`@true`) must not clear required_evidence: test.
99
+ if (stdoutLines.length === 0 && stderrLines.length === 0) {
100
+ throw new Error(`Agent report: Verification produced no output for test evidence (${command}). A bounded test command must print observable stdout or stderr.`);
101
+ }
89
102
  const details = [
90
103
  `$ ${command}`,
91
104
  ...stdoutLines,
92
105
  ...stderrLines,
93
- ...(stdoutLines.length === 0 && stderrLines.length === 0
94
- ? ["(no stdout/stderr — command exited with the status below)"]
95
- : []),
96
106
  `exit ${result.code}`,
97
107
  ].map((line) => line.slice(0, 4_000)).slice(0, 100);
98
- if (result.code !== 0) {
99
- // Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
100
- throw new Error(`Agent report: Verification failed (${command}): ${details.join("\n").slice(0, 2_000)}`);
101
- }
102
108
  if (details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN) {
103
109
  throw new Error(`Agent report: Verification produced insufficient output for test evidence (${command})`);
104
110
  }
111
+ // Keep agent test prose as supplement; Bridge witness is the authoritative entry.
105
112
  const withoutThinTest = input.report.evidence.filter((item) => {
106
113
  if (item.kind !== "test")
107
114
  return true;
@@ -119,6 +126,7 @@ export async function ensureTestEvidence(input) {
119
126
  name: command,
120
127
  details,
121
128
  acceptance_criteria: criteriaForTestEvidence(input.report),
129
+ witness: "bridge",
122
130
  },
123
131
  ],
124
132
  };
package/dist/execution.js CHANGED
@@ -558,8 +558,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
558
558
  }
559
559
  catch (error) {
560
560
  const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
561
- // classifyFinalizeFailure: contract/environment → non-retryable; forge transport → retryable;
562
- // unknown faults stay retryable as "Bridge finalize interrupted" (not laundered as contract).
561
+ // classifyFinalizeFailure: forge transport → retryable; contract/environment/unknown → fail closed
562
+ // (unknown prefixed as "Bridge finalize interrupted", not laundered as contract).
563
563
  const classified = classifyFinalizeFailure(message);
564
564
  await queueTerminal(client, taskId, {
565
565
  action: "fail",
@@ -729,7 +729,11 @@ async function prepareDelivery(client, attemptId, taskId, report) {
729
729
  .digest("hex")}`);
730
730
  const response = await client.attemptRequest(taskId, "artifacts", {
731
731
  kind: item.kind, name: item.name, uri, digest,
732
- metadata: { details: item.details, acceptance_criteria: item.acceptance_criteria },
732
+ metadata: {
733
+ details: item.details,
734
+ acceptance_criteria: item.acceptance_criteria,
735
+ ...(item.witness === "bridge" ? { witness: "bridge" } : {}),
736
+ },
733
737
  idempotency_key: `bridge:artifact:${attemptId}:${index + 1}`,
734
738
  });
735
739
  const artifactId = z.string().uuid().parse(response.artifact_id);
package/dist/mcp.js CHANGED
@@ -42,7 +42,13 @@ export function createBridgeMcpServer(client) {
42
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
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
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)));
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) => {
46
+ // Witness is Bridge-authored after a real command run — never accept an agent-claimed stamp.
47
+ const metadata = input.metadata && typeof input.metadata === "object"
48
+ ? Object.fromEntries(Object.entries(input.metadata).filter(([key]) => key !== "witness"))
49
+ : input.metadata;
50
+ return result(await client.attemptRequest(input.task_id, "artifacts", { ...input, metadata }));
51
+ });
46
52
  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
53
  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
54
  return server;
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Phase 2: when Connect queued a workspace intent, Bridge applies it once
3
- * (ops.env + install/restart) then acks so the next LaunchAgent start uses the new path.
3
+ * (ops.env + install/restart) then acks. Ack runs only after install succeeds so a
4
+ * failed apply leaves the intent queued for the next heartbeat (manual ops switch remains fallback).
5
+ * After a successful restart, currentWorkspace matches the target so we do not re-apply.
4
6
  */
5
7
  import { resolve } from "node:path";
6
8
  import { homedir } from "node:os";
@@ -41,12 +43,13 @@ export async function maybeApplyOnShiftIntent(input) {
41
43
  ...(repo ? { CONDUIT_REPO: repo } : {}),
42
44
  });
43
45
  }
44
- // Ack before restart so a rebound service does not re-apply forever.
46
+ console.log(`Applying on-shift workspace intent: ${target}${repo ? ` (${repo})` : ""}`);
47
+ await input.runInstall(target, repo);
48
+ // Ack only after install succeeds. A rebound service that already matches the target
49
+ // short-circuits above and will not re-apply; Bound clears any leftover intent.
45
50
  await input.client.request("/runner/v1/machine/ack-on-shift-intent", {
46
51
  method: "POST",
47
52
  body: "{}",
48
53
  });
49
- console.log(`Applying on-shift workspace intent: ${target}${repo ? ` (${repo})` : ""}`);
50
- await input.runInstall(target, repo);
51
54
  return true;
52
55
  }
package/dist/ops.js CHANGED
@@ -243,13 +243,6 @@ export async function runOps(verb, argv = [], deps = {}) {
243
243
  CONDUIT_WORKSPACE: workspace,
244
244
  ...(repo ? { CONDUIT_REPO: repo } : {}),
245
245
  });
246
- // Local apply owns the checkout; clear queued intent so heartbeat does not re-apply.
247
- try {
248
- await client.request("/runner/v1/machine/ack-on-shift-intent", { method: "POST", body: "{}" });
249
- }
250
- catch {
251
- // Older control planes omit ack — local ops.env still wins.
252
- }
253
246
  console.log(`Updated ${envPath}`);
254
247
  console.log(`On-shift workspace: ${workspace}${repo ? ` (${repo})` : ""}`);
255
248
  // Same install path as ops install — restart runner against the new checkout.
@@ -262,6 +255,13 @@ export async function runOps(verb, argv = [], deps = {}) {
262
255
  CONDUIT_REPO: repo || switched.CONDUIT_REPO,
263
256
  },
264
257
  });
258
+ // Clear queued intent only after install succeeds so a failed apply can retry via heartbeat.
259
+ try {
260
+ await client.request("/runner/v1/machine/ack-on-shift-intent", { method: "POST", body: "{}" });
261
+ }
262
+ catch {
263
+ // Older control planes omit ack — local ops.env still wins.
264
+ }
265
265
  return;
266
266
  }
267
267
  if (verb === "connect") {
package/dist/service.js CHANGED
@@ -2,7 +2,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { realpathSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  export const SERVICE_LABEL = "io.miraland.conduit-runner";
7
7
  function configDir() {
8
8
  return join(homedir(), ".config", "conduit");
@@ -118,6 +118,22 @@ function run(command, args) {
118
118
  throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
119
119
  }
120
120
  }
121
+ function shellQuote(value) {
122
+ return `'${value.replaceAll("'", `'\\''`)}'`;
123
+ }
124
+ /**
125
+ * `launchctl bootout` of a loaded service kills that service's whole process group — including
126
+ * this process when install-service runs from inside the runner it replaces (Connect on-shift
127
+ * auto-apply). Observed in the field (2026-08-05): the runner died at bootout, the bootstrap on
128
+ * the next line never ran, and the machine stayed Binding incomplete with the service unloaded.
129
+ * The swap therefore runs in a detached helper that survives the bootout; failures append to the
130
+ * runner log.
131
+ */
132
+ export function launchdSwapCommand(domain, plistPath, log) {
133
+ const plist = shellQuote(plistPath);
134
+ const logQ = shellQuote(log);
135
+ return `sleep 1; launchctl bootout ${domain}/${SERVICE_LABEL} 2>>${logQ}; launchctl bootstrap ${domain} ${plist} 2>>${logQ} || launchctl load -w ${plist} 2>>${logQ}`;
136
+ }
121
137
  export async function installRunnerService(options = {}) {
122
138
  const host = platform();
123
139
  if (host !== "darwin" && host !== "linux") {
@@ -129,12 +145,19 @@ export async function installRunnerService(options = {}) {
129
145
  await mkdir(dirname(plistPath), { recursive: true });
130
146
  await mkdir(dirname(logPath()), { recursive: true });
131
147
  await writeFile(plistPath, launchdPlist(programArguments, logPath()), { mode: 0o644 });
132
- // Prefer modern bootout/bootstrap; fall back to load for older macOS.
133
- spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${SERVICE_LABEL}`], { encoding: "utf8" });
134
- const boot = spawnSync("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, plistPath], { encoding: "utf8" });
135
- if (boot.status !== 0) {
136
- run("launchctl", ["load", "-w", plistPath]);
148
+ // Detached helper (bootout → bootstrap → load fallback): see launchdSwapCommand for why the
149
+ // swap must survive this process dying at bootout.
150
+ const domain = `gui/${process.getuid?.() ?? 501}`;
151
+ spawn("/bin/sh", ["-c", launchdSwapCommand(domain, plistPath, logPath())], { detached: true, stdio: "ignore" }).unref();
152
+ // Confirm the reloaded service when this process survives to see it (an interactive install).
153
+ // A self-replacing runner dies at the helper's bootout and the helper still finishes the swap.
154
+ let loaded = false;
155
+ for (let attempt = 0; attempt < 16 && !loaded; attempt++) {
156
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, attempt === 0 ? 2000 : 500));
157
+ loaded = spawnSync("launchctl", ["print", `${domain}/${SERVICE_LABEL}`], { encoding: "utf8" }).status === 0;
137
158
  }
159
+ if (!loaded)
160
+ throw new Error(`launchd did not load ${SERVICE_LABEL}; see ${logPath()}`);
138
161
  await writeFile(serviceStatePath(), `${JSON.stringify({ platform: "darwin", programArguments, options, installed_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
139
162
  return { path: plistPath, platform: "darwin" };
140
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.11.10",
3
+ "version": "0.11.12",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {