@miraland-labs/conduit-bridge 0.14.8 → 0.15.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/cli.js CHANGED
@@ -20,6 +20,7 @@ import { loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
20
20
  import { pumpExecutionSlots, renewLeases } from "./execution.js";
21
21
  import { installRunnerService, uninstallRunnerService } from "./service.js";
22
22
  import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, unavailableWorkspacePreflight } from "./preflight.js";
23
+ import { executeNextInvestigation } from "./investigation.js";
23
24
  import { bridgeVersion } from "./version.js";
24
25
  const [command] = process.argv.slice(2);
25
26
  /** Install-free form shown in help/output so clean laptops never need `conduit` on PATH. */
@@ -495,6 +496,11 @@ async function runner() {
495
496
  process.exit(0);
496
497
  }
497
498
  await renewLeases(client, config);
499
+ if (workspace && onlineDriverIds(config).length) {
500
+ // Observation runs before authoring: an owner waiting on an answer should not queue behind
501
+ // a long delivery, and the control plane already counted this slot as busy.
502
+ progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
503
+ }
498
504
  if (workspace && onlineDriverIds(config).length) {
499
505
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
500
506
  heartbeat: async () => {
package/dist/execution.js CHANGED
@@ -7,8 +7,11 @@ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, ext
7
7
  import { assertClassFloor } from "./execution-class.js";
8
8
  import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
9
9
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
10
+ import { execFile } from "node:child_process";
11
+ import { promisify } from "node:util";
10
12
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
11
13
  import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
14
+ const execFileAsync = promisify(execFile);
12
15
  import { captureVerificationFailure, ensureTestEvidence } from "./ensure-test-evidence.js";
13
16
  /** Keep a Mac awake only while an assignment is active; display sleep remains allowed. */
14
17
  export function startIdleSleepGuard(options = {}) {
@@ -969,7 +972,12 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
969
972
  grants,
970
973
  verificationCommands: attemptBrief?.verification ?? [],
971
974
  });
972
- validateDeliveryReport(report, spec, grants);
975
+ // Ask git what changed before trusting what the report says changed.
976
+ const startCommit = executionContract.requested_base_commit && executionContract.claimed_head
977
+ ? await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, executionContract.claimed_head)
978
+ : null;
979
+ const actualPaths = startCommit ? await changedPathsSince(attemptWorkspace, startCommit) : null;
980
+ validateDeliveryReport(report, spec, grants, actualPaths ?? undefined);
973
981
  }
974
982
  catch (error) {
975
983
  const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
@@ -1198,13 +1206,13 @@ async function prepareDelivery(client, attemptId, taskId, report) {
1198
1206
  },
1199
1207
  };
1200
1208
  }
1201
- export function validateDeliveryReport(report, spec, grants = []) {
1209
+ export function validateDeliveryReport(report, spec, grants = [], actualPaths) {
1202
1210
  // Research findings live in the report. Do not demand a published pack URL for work_role=research
1203
1211
  // even when a planner mis-labeled the package as deliverable=artifact.
1204
1212
  const artifactDelivery = spec.deliverable === "artifact" && spec.work_role !== "research";
1205
1213
  // Artifact packs are not git landings — do not demand head_commit when the planner put
1206
1214
  // `.conduit/artifacts/**` in change_scope as a write hint (path-4 canary incident).
1207
- validateChangeScope(report, spec.change_scope ?? [], { requireHeadCommit: !artifactDelivery });
1215
+ validateChangeScope(report, spec.change_scope ?? [], { requireHeadCommit: !artifactDelivery, actualPaths });
1208
1216
  if (artifactDelivery) {
1209
1217
  const published = report.evidence.some((item) => {
1210
1218
  if (!["preview", "research", "documentation"].includes(item.kind))
@@ -1271,6 +1279,45 @@ function referenceKind(kind) {
1271
1279
  return "document";
1272
1280
  return "other";
1273
1281
  }
1282
+ /**
1283
+ * What this attempt actually changed, according to git rather than to the agent's prose.
1284
+ *
1285
+ * The scope check used to compare approved paths against strings the agent wrote about itself, so a
1286
+ * run that edited an approved file and named it "tools.ts" failed the contract and spent an attempt
1287
+ * on a path format. Git knows the truth, and Bridge holds the worktree: this is both stricter (a
1288
+ * change cannot be omitted from the report to escape the check) and more forgiving (how the agent
1289
+ * phrases a path stops mattering).
1290
+ *
1291
+ * Null when git cannot answer — an unknown must not fail a delivery, so the caller falls back to the
1292
+ * report exactly as before.
1293
+ */
1294
+ export async function changedPathsSince(worktree, startCommit) {
1295
+ const run = async (args) => {
1296
+ try {
1297
+ const { stdout } = await execFileAsync("git", ["-C", worktree, ...args], { timeout: 30_000, maxBuffer: 8_000_000 });
1298
+ return stdout;
1299
+ }
1300
+ catch {
1301
+ return null;
1302
+ }
1303
+ };
1304
+ const committed = await run(["diff", "--name-only", `${startCommit}..HEAD`]);
1305
+ if (committed === null)
1306
+ return null;
1307
+ const paths = new Set(committed.split("\n").map((line) => line.trim()).filter(Boolean));
1308
+ // Work the agent left uncommitted still lands in the delivery for lands=false packages.
1309
+ const pending = await run(["status", "--porcelain"]);
1310
+ for (const line of (pending ?? "").split("\n")) {
1311
+ const entry = line.slice(3).trim();
1312
+ if (!entry)
1313
+ continue;
1314
+ // A rename reports "old -> new"; the new path is the one that exists.
1315
+ const path = entry.includes(" -> ") ? entry.slice(entry.lastIndexOf(" -> ") + 4).trim() : entry;
1316
+ if (path)
1317
+ paths.add(path.replace(/^"|"$/g, ""));
1318
+ }
1319
+ return [...paths];
1320
+ }
1274
1321
  export function validateChangeScope(report, changeScope, options = {}) {
1275
1322
  if (!changeScope.length)
1276
1323
  return;
@@ -1278,9 +1325,11 @@ export function validateChangeScope(report, changeScope, options = {}) {
1278
1325
  if (requireHeadCommit && !report.head_commit) {
1279
1326
  throw new Error("Repository changes require a delivered head commit");
1280
1327
  }
1281
- if (!report.changes.length)
1328
+ // Git's answer when we have it; the agent's description only when we do not.
1329
+ const paths = options.actualPaths
1330
+ ?? report.changes.map((change) => change.split(/\s+[—-]\s+/, 1)[0].trim().replaceAll("\\", "/"));
1331
+ if (!paths.length)
1282
1332
  return;
1283
- const paths = report.changes.map((change) => change.split(/\s+[—-]\s+/, 1)[0].trim().replaceAll("\\", "/"));
1284
1333
  const outside = paths.filter((path) => {
1285
1334
  if (!path || path.startsWith("/") || path.split("/").includes(".."))
1286
1335
  return true;
@@ -0,0 +1,194 @@
1
+ /** Read-only investigation lane on Bridge (Slice 5).
2
+ *
3
+ * An investigation answers one question against one pinned commit and returns a bounded brief. It
4
+ * is not an assignment: no delivery, no pull request, no evidence packet, no task state. The run is
5
+ * held to repo_read + test_run by the same driver projection that enforces read-only diagnosis, and
6
+ * the worktree it reads is deleted whichever way the run ends.
7
+ */
8
+ import { z } from "zod";
9
+ import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
10
+ import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
11
+ import { redactSecrets } from "./config.js";
12
+ import { DRIVERS, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
13
+ import { pickDriverForClaim, supportsReadOnlyDiagnosis } from "./drivers.js";
14
+ const budgetSchema = z.object({
15
+ max_files: z.number().int().min(1).max(24).optional().default(8),
16
+ max_duration_ms: z.number().int().min(10_000).max(15 * 60_000).optional().default(5 * 60_000),
17
+ }).passthrough();
18
+ export const investigationAssignmentSchema = z.object({
19
+ id: z.string().uuid(),
20
+ kind: z.literal("repository"),
21
+ question: z.string().min(1).max(4_000),
22
+ grants: z.array(z.string()).default([]),
23
+ budget: budgetSchema.optional().default({}),
24
+ repository_fingerprint: z.string().nullable().optional().default(null),
25
+ commit: z.string().nullable().optional().default(null),
26
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
27
+ });
28
+ /** Mirrors the control plane's bounded result contract; anything else is refused before settlement. */
29
+ const investigationBriefSchema = z.object({
30
+ summary: z.string().trim().min(1).max(4_000),
31
+ findings: z.array(z.string().trim().min(1).max(1_000)).max(12).default([]),
32
+ verification: z.array(z.string().trim().min(1).max(1_000)).max(8).default([]),
33
+ limitations: z.array(z.string().trim().min(1).max(1_000)).max(8).default([]),
34
+ files_read: z.number().int().min(0).max(24).default(0),
35
+ bytes_read: z.number().int().min(0).max(512 * 1024).default(0),
36
+ commands_run: z.array(z.string().trim().min(1).max(200)).max(10).default([]),
37
+ });
38
+ export function buildInvestigationPrompt(assignment, commit) {
39
+ return [
40
+ "You are Conductor's read-only investigation lane. Answer exactly one question about this repository at one pinned commit.",
41
+ "You have repo_read and test_run only. Do not edit, create, delete, format, commit, branch, push, install, or change any file.",
42
+ "Read the actual code and run only bounded verification commands that already exist in the project. Never start a server, deploy, or call a production system.",
43
+ "Ground every finding in files and symbols you actually read. If the answer cannot be settled at this commit, say so in limitations rather than guessing.",
44
+ "Do not quote secrets, credentials, tokens, or file bodies. Name paths and symbols instead.",
45
+ "",
46
+ `PINNED COMMIT ${commit}`,
47
+ `QUESTION\n${assignment.question}`,
48
+ `BUDGET\nAt most ${assignment.budget.max_files} files.`,
49
+ "",
50
+ "Return only one fenced ```json object with exactly this shape:",
51
+ '{"summary":"direct answer to the question","findings":["grounded fact with file/symbol"],"verification":["command you actually ran"],"limitations":["what you could not settle"],"files_read":0,"bytes_read":0,"commands_run":["command you actually ran"]}',
52
+ "summary is a string; the other list fields are arrays of strings. Close the fence and add no prose after it.",
53
+ ].join("\n");
54
+ }
55
+ /** One message for every unusable answer, so the retry decision reads it rather than guessing. */
56
+ export const UNUSABLE_BRIEF = "Investigation returned no usable brief";
57
+ export function parseInvestigationBrief(text) {
58
+ let candidate;
59
+ // A missing fence and a malformed body are the same outcome to the owner: no answer.
60
+ try {
61
+ candidate = parseJsonObjectCandidate(extractAgentReportJsonText(text));
62
+ }
63
+ catch {
64
+ throw new Error(UNUSABLE_BRIEF);
65
+ }
66
+ const parsed = investigationBriefSchema.safeParse(candidate);
67
+ if (!parsed.success)
68
+ throw new Error(UNUSABLE_BRIEF);
69
+ return parsed.data;
70
+ }
71
+ async function settle(client, investigationId, body) {
72
+ await client.request(`/runner/v1/investigations/${investigationId}/settle`, {
73
+ method: "POST",
74
+ body: JSON.stringify(body),
75
+ });
76
+ }
77
+ /**
78
+ * Poll, claim and run one investigation. Returns true when one was executed, so the caller's cycle
79
+ * knows it made progress.
80
+ */
81
+ export async function executeNextInvestigation(client, config, workspace, brief, timeoutMs) {
82
+ const data = await client.request("/runner/v1/investigations");
83
+ const assignments = z.array(investigationAssignmentSchema).parse(data.investigations ?? []);
84
+ const assignment = assignments[0];
85
+ if (!assignment)
86
+ return false;
87
+ const driverId = pickDriverForClaim(config, null, supportsReadOnlyDiagnosis);
88
+ const driver = driverId ? DRIVERS[driverId] : null;
89
+ if (!driver) {
90
+ // Nothing local can hold the run read-only. Leave it claimable by a capable machine instead of
91
+ // taking the lease and failing under it.
92
+ console.error(`Investigation ${assignment.id} skipped: no online lane can enforce read-only access.`);
93
+ return false;
94
+ }
95
+ const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
96
+ const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
97
+ const commit = assignment.commit;
98
+ const leaseToken = `${crypto.randomUUID()}${crypto.randomUUID()}`;
99
+ if (!commit) {
100
+ await claimThenSettle(client, assignment.id, leaseToken, {
101
+ lease_token: leaseToken, status: "failed", error: "investigation_commit_missing", retryable: false,
102
+ });
103
+ return true;
104
+ }
105
+ if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint) {
106
+ await claimThenSettle(client, assignment.id, leaseToken, {
107
+ lease_token: leaseToken, status: "failed", error: "workspace_repository_mismatch", retryable: true,
108
+ });
109
+ return true;
110
+ }
111
+ if (!(await ensureCommitAvailable(workspace, commit).catch(() => false))) {
112
+ await claimThenSettle(client, assignment.id, leaseToken, {
113
+ lease_token: leaseToken, status: "failed", error: "commit_not_available", retryable: true,
114
+ });
115
+ return true;
116
+ }
117
+ await client.request(`/runner/v1/investigations/${assignment.id}/claim`, {
118
+ method: "POST",
119
+ body: JSON.stringify({ lease_token: leaseToken }),
120
+ });
121
+ console.log(`Claiming investigation ${assignment.id} at ${commit}`);
122
+ let attemptWorkspace = null;
123
+ try {
124
+ attemptWorkspace = await createAttemptWorktree({
125
+ sourceWorkspace: workspace,
126
+ attemptId: assignment.id,
127
+ startCommit: commit,
128
+ });
129
+ const result = await driver.run({
130
+ prompt: buildInvestigationPrompt(assignment, commit),
131
+ workspace: attemptWorkspace,
132
+ grants: assignment.grants,
133
+ verificationCommands: liveBrief?.verification ?? [],
134
+ deliverable: "repository",
135
+ // The same signal the read-only diagnosis lane uses; it is what denies edits in the driver.
136
+ workRole: "diagnose",
137
+ executionClass: "verify",
138
+ timeoutMs: Math.min(timeoutMs ?? assignment.budget.max_duration_ms, assignment.budget.max_duration_ms),
139
+ });
140
+ if (result.status === "failed") {
141
+ const message = result.error ?? "Investigation run failed";
142
+ await settle(client, assignment.id, { lease_token: leaseToken, status: "failed", error: redactSecrets(message).slice(0, 2_000), retryable: true });
143
+ console.error(`Investigation ${assignment.id} failed: ${redactSecrets(message)}`);
144
+ return true;
145
+ }
146
+ const observed = parseInvestigationBrief(result.resultText ?? "");
147
+ await settle(client, assignment.id, {
148
+ lease_token: leaseToken,
149
+ status: "completed",
150
+ result: {
151
+ summary: observed.summary,
152
+ findings: observed.findings,
153
+ verification: observed.verification,
154
+ limitations: observed.limitations,
155
+ witness: {
156
+ repository_fingerprint: assignment.repository_fingerprint ?? liveRepository ?? "unknown",
157
+ commit,
158
+ observed_at: new Date().toISOString(),
159
+ files_read: observed.files_read,
160
+ bytes_read: observed.bytes_read,
161
+ commands_run: observed.commands_run,
162
+ },
163
+ },
164
+ });
165
+ console.log(`Investigation ${assignment.id} settled with a bounded brief.`);
166
+ }
167
+ catch (error) {
168
+ const message = error instanceof Error ? error.message : "investigation_failed";
169
+ await settle(client, assignment.id, {
170
+ lease_token: leaseToken,
171
+ status: "failed",
172
+ error: redactSecrets(message).slice(0, 2_000),
173
+ // A malformed brief is worth one more try; a broken worktree is not fixed by repeating it.
174
+ retryable: message === UNUSABLE_BRIEF,
175
+ }).catch((settleError) => console.error(`Investigation settle failed: ${redactSecrets(settleError instanceof Error ? settleError.message : "unknown")}`));
176
+ console.error(`Investigation ${assignment.id} failed: ${redactSecrets(message)}`);
177
+ }
178
+ finally {
179
+ if (attemptWorkspace) {
180
+ await removeAttemptWorktree(workspace, attemptWorkspace)
181
+ .catch((error) => console.error(`Investigation worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+ /** Claim only to report why the run cannot start; the control plane needs the reason, not silence. */
187
+ async function claimThenSettle(client, investigationId, leaseToken, body) {
188
+ await client.request(`/runner/v1/investigations/${investigationId}/claim`, {
189
+ method: "POST",
190
+ body: JSON.stringify({ lease_token: leaseToken }),
191
+ });
192
+ await settle(client, investigationId, body);
193
+ console.error(`Investigation ${investigationId} could not start: ${body.error}`);
194
+ }
package/dist/preflight.js CHANGED
@@ -18,7 +18,7 @@ function modelsFingerprint(config) {
18
18
  }
19
19
  // Protocol 5 makes the lease-token claim contract explicit. Older Bridges may report a green
20
20
  // preflight but cannot claim against the current control plane, so they must be admitted as stale.
21
- export const BRIDGE_PROTOCOL_VERSION = 5;
21
+ export const BRIDGE_PROTOCOL_VERSION = 6;
22
22
  const execFileAsync = promisify(execFile);
23
23
  async function defaultCommandRunner(command, args, cwd) {
24
24
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.14.8",
3
+ "version": "0.15.0",
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": {