@miraland-labs/conduit-bridge 0.9.7 → 0.9.9

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
- **Current npm:** `0.9.6` — **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, optional `--ensure-checkout`.
5
+ **Package version:** `0.9.9` — heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. Attempt worktrees fetch/start from the initiative base when the source tip is behind or history was rewritten, without resetting the source checkout. Also includes **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, and optional `--ensure-checkout`. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -63,6 +63,18 @@ npx @miraland-labs/conduit-bridge disconnect --yes
63
63
 
64
64
  **Capacity:** Connect-approved `lease_capacity` (1–4) is a **shared pool** for all online lanes on that computer.
65
65
 
66
+ ## Readiness and lane status
67
+
68
+ Before agent spend, Bridge checks that the workspace is readable, matches the expected repository,
69
+ and is clean; each online driver must be installed, compatible with its configured fuel, and signed
70
+ in when local fuel is used. The same bounded report is sent on heartbeat so Conduit can place an
71
+ environment failure on Hold without consuming an execution attempt. Fix the named issue and let a
72
+ fresh heartbeat land before choosing **Recheck** in Activity.
73
+
74
+ Conduit labels an exact driver/CLI version **Certified** only after two consecutive real canaries:
75
+ repository delivery with PR/evidence and a successful rework cycle. All other lanes are
76
+ **Experimental**. A lane runs only when its computer operator explicitly brings it online.
77
+
66
78
  ## Grant enforcement
67
79
 
68
80
  | Driver | Fuel | Mechanism |
package/dist/brief.js CHANGED
@@ -43,6 +43,26 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
43
43
  throw error;
44
44
  }
45
45
  }
46
+ async function fetchOrigin(workspace) {
47
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
48
+ timeout: 120_000,
49
+ windowsHide: true,
50
+ });
51
+ }
52
+ /** Null when commits do not share history (e.g. orphan / force-pushed replacement tip). */
53
+ async function gitMergeBase(workspace, left, right) {
54
+ try {
55
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "merge-base", left, right], {
56
+ timeout: 10_000,
57
+ windowsHide: true,
58
+ });
59
+ const sha = stdout.trim().toLowerCase();
60
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
46
66
  /** True when `sha` is a local commit object, optionally after fetching it from origin. */
47
67
  export async function ensureCommitAvailable(workspace, sha) {
48
68
  const present = async () => {
@@ -67,10 +87,7 @@ export async function ensureCommitAvailable(workspace, sha) {
67
87
  }
68
88
  catch {
69
89
  try {
70
- await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
71
- timeout: 120_000,
72
- windowsHide: true,
73
- });
90
+ await fetchOrigin(workspace);
74
91
  }
75
92
  catch {
76
93
  return false;
@@ -78,6 +95,47 @@ export async function ensureCommitAvailable(workspace, sha) {
78
95
  }
79
96
  return present();
80
97
  }
98
+ /**
99
+ * Choose the commit an attempt worktree starts from for a required initiative base.
100
+ *
101
+ * Never rewrites the source checkout HEAD. Fetches missing objects when needed, prefers the claimed
102
+ * head when it already contains the base (rework), advances to the base when the workspace is merely
103
+ * behind, and falls back to the contract base when history was rewritten (force-push) so the owner
104
+ * does not have to manually reset before Recheck.
105
+ */
106
+ export async function resolveAttemptStartCommit(workspace, requestedBase, claimedHead) {
107
+ if (!requestedBase)
108
+ return claimedHead;
109
+ const base = requestedBase.toLowerCase();
110
+ const claimed = claimedHead.toLowerCase();
111
+ if (await isBaseCommitAncestor(workspace, base, claimed).catch(() => false))
112
+ return claimed;
113
+ if (!(await ensureCommitAvailable(workspace, base).catch(() => false)))
114
+ return null;
115
+ await ensureCommitAvailable(workspace, claimed).catch(() => false);
116
+ if (await isBaseCommitAncestor(workspace, base, claimed).catch(() => false))
117
+ return claimed;
118
+ if (await isBaseCommitAncestor(workspace, claimed, base).catch(() => false))
119
+ return base;
120
+ // Shallow tip fetches can hide ancestry; deepen once before treating history as rewritten.
121
+ try {
122
+ await fetchOrigin(workspace);
123
+ }
124
+ catch {
125
+ /* offline / no origin — continue with objects already present */
126
+ }
127
+ if (await isBaseCommitAncestor(workspace, base, claimed).catch(() => false))
128
+ return claimed;
129
+ if (await isBaseCommitAncestor(workspace, claimed, base).catch(() => false))
130
+ return base;
131
+ // Unrelated histories (orphan / force-pushed default branch): honor the contract base for a fresh
132
+ // attempt worktree. Related-but-diverged tips can carry rework commits — refuse rather than drop them.
133
+ if (!(await ensureCommitAvailable(workspace, base).catch(() => false)))
134
+ return null;
135
+ if (await gitMergeBase(workspace, base, claimed) === null)
136
+ return base;
137
+ return null;
138
+ }
81
139
  async function gitRemoteUrl(workspace) {
82
140
  try {
83
141
  const { common } = await gitDirectories(workspace);
package/dist/driver.js CHANGED
@@ -345,7 +345,7 @@ export function codexSandboxForGrants(grants) {
345
345
  }
346
346
  /**
347
347
  * Codex exec arguments. The sandbox is the enforcement boundary: network stays
348
- * off inside workspace-write unless the assignment actually holds pr_create.
348
+ * off inside workspace-write unless the canonical contract requires external_network.
349
349
  */
350
350
  export function codexExecArgs(input, sandbox) {
351
351
  // exec is non-interactive by design (no approval flag), and `exec resume`
@@ -355,7 +355,7 @@ export function codexExecArgs(input, sandbox) {
355
355
  : ["exec", "--json", "--sandbox", sandbox];
356
356
  if (input.model)
357
357
  args.push("-m", input.model);
358
- if (sandbox === "workspace-write" && input.grants.includes("pr_create")) {
358
+ if (sandbox === "workspace-write" && input.capabilities?.includes("external_network")) {
359
359
  args.push("-c", "sandbox_workspace_write.network_access=true");
360
360
  }
361
361
  if (input.resumeSessionId)
package/dist/execution.js CHANGED
@@ -5,7 +5,7 @@ import { redactSecrets } from "./config.js";
5
5
  import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
6
6
  import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
7
7
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
8
- import { buildWorkspaceBrief, ensureCommitAvailable, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
8
+ import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
9
9
  import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
10
10
  /** Feedback text for changes_requested summaries (plain string or `{ feedback }`). */
11
11
  function changesRequestedFeedback(summary) {
@@ -45,6 +45,7 @@ const taskDetailSchema = z.object({
45
45
  const taskSpecSchema = z.object({
46
46
  goal: z.string().optional(), scope: z.array(z.string()).optional(), boundaries: z.array(z.string()).optional(),
47
47
  acceptance: z.array(z.string()).optional(), required_evidence: z.array(z.enum(evidenceKinds)).optional(),
48
+ required_capabilities: z.array(z.string()).optional(),
48
49
  change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
49
50
  repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
50
51
  risk_level: z.string().optional(),
@@ -229,7 +230,7 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
229
230
  rejection = "workspace_head_changed";
230
231
  }
231
232
  if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
232
- const startHead = await resolveStartHead(workspace, assignment.requested_base_commit, assignment.claimed_head);
233
+ const startHead = await resolveAttemptStartCommit(workspace, assignment.requested_base_commit, assignment.claimed_head);
233
234
  if (!startHead)
234
235
  rejection = "base_not_ancestor";
235
236
  }
@@ -245,25 +246,6 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
245
246
  await client.claim(assignment.id, assignment.attempt_id, driverId ? { driverId } : undefined);
246
247
  return assignment.id;
247
248
  }
248
- /**
249
- * When a sibling merge advances task.base_commit past the machine checkout, the required base is a
250
- * descendant of claimed_head — not an ancestor. Fetch it and start from the advanced base instead of looping.
251
- */
252
- async function resolveStartHead(workspace, requestedBase, claimedHead) {
253
- if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
254
- return claimedHead;
255
- await ensureCommitAvailable(workspace, requestedBase).catch(() => false);
256
- if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
257
- return claimedHead;
258
- // Workspace is behind the required base (dependent package after merge).
259
- if (await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false))
260
- return requestedBase;
261
- if (await ensureCommitAvailable(workspace, requestedBase).catch(() => false)
262
- && await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false)) {
263
- return requestedBase;
264
- }
265
- return null;
266
- }
267
249
  export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
268
250
  if (Object.keys(config.activeAttempts).length)
269
251
  return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
@@ -303,7 +285,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
303
285
  }
304
286
  let worktreeStart = startCommit;
305
287
  if (executionContract.requested_base_commit) {
306
- const resolved = await resolveStartHead(workspace, executionContract.requested_base_commit, startCommit);
288
+ const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
307
289
  if (!resolved) {
308
290
  await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
309
291
  return;
@@ -399,6 +381,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
399
381
  prompt,
400
382
  workspace: attemptWorkspace,
401
383
  grants,
384
+ capabilities: spec.required_capabilities ?? [],
402
385
  verificationCommands: liveBrief?.verification ?? [],
403
386
  resumeSessionId,
404
387
  timeoutMs,
@@ -436,6 +419,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
436
419
  // Preserve only existing read authority. No write, test, branch, or push
437
420
  // capability is available while the agent repairs the response envelope.
438
421
  grants: grants.filter((grant) => grant === "repo_read"),
422
+ capabilities: [],
439
423
  verificationCommands: [],
440
424
  resumeSessionId: result.sessionId ?? undefined,
441
425
  timeoutMs,
package/dist/preflight.js CHANGED
@@ -3,7 +3,7 @@ import { promisify } from "node:util";
3
3
  import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
4
4
  import { hasAntigravityLogin, hasClaudeLogin, hasOpenAiLogin, hasOpenCodeLogin, resolveCodexExecutable, } from "./driver.js";
5
5
  import { localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel } from "./drivers.js";
6
- export const BRIDGE_PROTOCOL_VERSION = 1;
6
+ export const BRIDGE_PROTOCOL_VERSION = 2;
7
7
  const execFileAsync = promisify(execFile);
8
8
  async function defaultCommandRunner(command, args, cwd) {
9
9
  try {
@@ -70,22 +70,30 @@ export async function runBridgePreflight(input, deps = {}) {
70
70
  && normalizeRepositoryUrl(brief.repository) !== normalizeRepositoryUrl(input.expectedRepository)) {
71
71
  issues.push({ code: "workspace_repository_mismatch" });
72
72
  }
73
- const driverIssues = await Promise.all(online.map(async (driver) => {
73
+ let workspaceClean = false;
74
+ if (brief) {
75
+ const status = await run("git", ["status", "--porcelain", "--untracked-files=normal"], input.workspace);
76
+ workspaceClean = status.code === 0 && status.stdout.trim().length === 0;
77
+ if (!workspaceClean)
78
+ issues.push({ code: status.code === 0 ? "workspace_dirty" : "workspace_unavailable" });
79
+ }
80
+ const driverChecks = await Promise.all(online.map(async (driver) => {
74
81
  const fuel = resolveDriverFuel(input.config, driver);
75
82
  if (localFuelOnlyDriver(driver) && fuel !== "local") {
76
- return { code: "driver_fuel_mismatch", driver };
83
+ return { issue: { code: "driver_fuel_mismatch", driver }, snapshot: { id: driver, version: null, ready: false } };
77
84
  }
78
85
  const version = await run(executableFor(driver), ["--version"], input.workspace);
86
+ const versionText = `${version.stdout}${version.stderr}`.trim().slice(0, 200) || null;
79
87
  if (version.code !== 0 || !`${version.stdout}${version.stderr}`.trim()) {
80
- return { code: "driver_missing", driver };
88
+ return { issue: { code: "driver_missing", driver }, snapshot: { id: driver, version: versionText, ready: false } };
81
89
  }
82
90
  if (fuel === "local" && !await localAuthenticationReady(driver, input.workspace, run)) {
83
- return { code: "driver_not_authenticated", driver };
91
+ return { issue: { code: "driver_not_authenticated", driver }, snapshot: { id: driver, version: versionText, ready: false } };
84
92
  }
85
- return null;
93
+ return { issue: null, snapshot: { id: driver, version: versionText, ready: true } };
86
94
  }));
87
- issues.push(...driverIssues.filter((issue) => issue !== null));
88
- return { ready: issues.length === 0, checked_at: new Date().toISOString(), issues };
95
+ issues.push(...driverChecks.map((check) => check.issue).filter((issue) => issue !== null));
96
+ return { ready: issues.length === 0, checked_at: new Date().toISOString(), workspace_clean: workspaceClean, drivers: driverChecks.map((check) => check.snapshot), issues };
89
97
  }
90
98
  let cached = null;
91
99
  /** Keep auth probes off the 15-second heartbeat hot path while still expiring readiness promptly. */
@@ -108,6 +116,8 @@ export function describePreflightIssue(issue) {
108
116
  return "Workspace has no origin repository";
109
117
  if (issue.code === "workspace_repository_mismatch")
110
118
  return "Workspace origin does not match CONDUIT_REPO";
119
+ if (issue.code === "workspace_dirty")
120
+ return "Workspace has uncommitted or untracked changes";
111
121
  if (issue.code === "driver_missing")
112
122
  return `Agent CLI is missing from PATH${lane}`;
113
123
  if (issue.code === "driver_not_authenticated")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {