@miraland-labs/conduit-bridge 0.16.29 → 0.16.32
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/attempt-worktree.js +10 -8
- package/dist/brief.js +22 -63
- package/dist/checkout.js +1 -13
- package/dist/cli.js +13 -7
- package/dist/config.js +19 -5
- package/dist/drivers.js +72 -6
- package/dist/ensure-land-commit.js +2 -11
- package/dist/ensure-pull-request.js +4 -14
- package/dist/ensure-test-evidence.js +68 -9
- package/dist/execution-class.js +34 -9
- package/dist/execution-facts.js +1 -16
- package/dist/execution.js +57 -27
- package/dist/git.js +48 -0
- package/dist/investigation.js +5 -2
- package/dist/normative-refs.js +2 -13
- package/dist/transport-fault.js +12 -0
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -7,6 +7,7 @@ import { constants } from "node:fs";
|
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { headCommitOrNull } from "./git.js";
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
export function attemptWorktreePath(sourceWorkspace, attemptId) {
|
|
12
13
|
return join(sourceWorkspace, ".conduit", "attempts", attemptId);
|
|
@@ -82,14 +83,18 @@ export async function ensureWorktreeGitIdentity(worktree, sourceWorkspace) {
|
|
|
82
83
|
await git("config", key, inherited || fallback);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
/** Removes the worktree; answers whether one was there to remove. */
|
|
85
87
|
export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
+
// The control plane names every settled repair on every assignment poll, because it holds no
|
|
89
|
+
// record of what this computer still has on disk. That is a statement of fact to reconcile
|
|
90
|
+
// against, not a queue to drain, so the caller asks whether anything actually went -- a release
|
|
91
|
+
// nobody performed is not an event, and logging one made the same eleven paths scroll past every
|
|
92
|
+
// fifteen seconds. It also keeps Git off a path that is already gone.
|
|
88
93
|
try {
|
|
89
94
|
await access(worktreePath, constants.F_OK);
|
|
90
95
|
}
|
|
91
96
|
catch {
|
|
92
|
-
return;
|
|
97
|
+
return false;
|
|
93
98
|
}
|
|
94
99
|
try {
|
|
95
100
|
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
@@ -101,6 +106,7 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
|
101
106
|
maxBuffer: 1_000_000,
|
|
102
107
|
}).catch(() => undefined);
|
|
103
108
|
}
|
|
109
|
+
return true;
|
|
104
110
|
}
|
|
105
111
|
/**
|
|
106
112
|
* F-07 safe resume: prove the interrupted attempt still owns this worktree path.
|
|
@@ -114,11 +120,7 @@ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePa
|
|
|
114
120
|
return false;
|
|
115
121
|
try {
|
|
116
122
|
await access(worktreePath, constants.F_OK);
|
|
117
|
-
|
|
118
|
-
timeout: 15_000,
|
|
119
|
-
maxBuffer: 1_000_000,
|
|
120
|
-
});
|
|
121
|
-
return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
|
|
123
|
+
return (await headCommitOrNull(worktreePath)) !== null;
|
|
122
124
|
}
|
|
123
125
|
catch {
|
|
124
126
|
return false;
|
package/dist/brief.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdir, readFile
|
|
2
|
-
import { join
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
6
|
+
import { gitOriginUrl, headCommitOrNull } from "./git.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const MANIFESTS = [
|
|
8
9
|
"package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
|
|
@@ -20,14 +21,22 @@ export async function buildWorkspaceBrief(workspace) {
|
|
|
20
21
|
.map((entry) => entry.name).sort().slice(0, 30);
|
|
21
22
|
const files = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
22
23
|
const manifests = MANIFESTS.filter((name) => files.has(name));
|
|
24
|
+
const declared = await readDeclaredVerification(workspace);
|
|
23
25
|
return {
|
|
24
|
-
repository: await
|
|
25
|
-
base_commit: await
|
|
26
|
+
repository: await gitOriginUrl(workspace),
|
|
27
|
+
base_commit: await headCommitOrNull(workspace),
|
|
26
28
|
modules,
|
|
27
29
|
manifests,
|
|
28
30
|
verification: await discoverVerificationCommands(workspace, files),
|
|
31
|
+
declared_verification: declared,
|
|
29
32
|
};
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Mirror of `src/conductor/repository.ts`. The server fingerprints the initiative URL and the Bridge
|
|
36
|
+
* fingerprints the workspace remote; dispatch requires the two to be equal, so a divergence stops
|
|
37
|
+
* matching silently. The Bridge is a published package and cannot import server code, so
|
|
38
|
+
* `test/matcher.test.ts` ("repository fingerprint agreement") holds the two spellings together.
|
|
39
|
+
*/
|
|
31
40
|
export function normalizeRepositoryUrl(url) {
|
|
32
41
|
let value = url.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
|
|
33
42
|
const ssh = value.match(/^git@([^:]+):(.+)$/);
|
|
@@ -143,61 +152,6 @@ export async function resolveAttemptStartCommit(workspace, requestedBase, claime
|
|
|
143
152
|
return base;
|
|
144
153
|
return null;
|
|
145
154
|
}
|
|
146
|
-
async function gitRemoteUrl(workspace) {
|
|
147
|
-
try {
|
|
148
|
-
const { common } = await gitDirectories(workspace);
|
|
149
|
-
const config = await readFile(join(common, "config"), "utf8");
|
|
150
|
-
const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
|
|
151
|
-
return remote?.[1] ?? null;
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
async function gitHeadCommit(workspace) {
|
|
158
|
-
try {
|
|
159
|
-
const { worktree, common } = await gitDirectories(workspace);
|
|
160
|
-
let head = String(await readFile(join(worktree, "HEAD"), "utf8")).trim();
|
|
161
|
-
// Follow one level of symbolic refs (HEAD → refs/heads/main, or a nested ref file).
|
|
162
|
-
for (let hop = 0; hop < 3; hop++) {
|
|
163
|
-
if (/^[0-9a-f]{40,64}$/i.test(head))
|
|
164
|
-
return head.toLowerCase();
|
|
165
|
-
const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
|
|
166
|
-
if (!ref)
|
|
167
|
-
return null;
|
|
168
|
-
try {
|
|
169
|
-
head = String(await readFile(join(common, ref), "utf8")).trim();
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
const packed = String(await readFile(join(common, "packed-refs"), "utf8"));
|
|
173
|
-
const line = packed.split("\n").find((entry) => {
|
|
174
|
-
const trimmed = entry.trim();
|
|
175
|
-
return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("^") && trimmed.endsWith(` ${ref}`);
|
|
176
|
-
});
|
|
177
|
-
const sha = line?.split(/\s+/)[0]?.trim() ?? null;
|
|
178
|
-
return sha && /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
async function gitDirectories(workspace) {
|
|
188
|
-
const dotGit = join(workspace, ".git");
|
|
189
|
-
const info = await stat(dotGit);
|
|
190
|
-
const pointer = info.isDirectory() ? null : (await readFile(dotGit, "utf8")).trim().match(/^gitdir:\s*(.+)$/)?.[1];
|
|
191
|
-
if (!info.isDirectory() && !pointer)
|
|
192
|
-
throw new Error("Invalid Git worktree metadata");
|
|
193
|
-
const worktree = info.isDirectory() ? dotGit : resolve(workspace, pointer);
|
|
194
|
-
try {
|
|
195
|
-
return { worktree, common: resolve(worktree, (await readFile(join(worktree, "commondir"), "utf8")).trim()) };
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return { worktree, common: worktree };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
155
|
/** Exported for tests — discovers bounded verification commands from workspace manifests. */
|
|
202
156
|
/**
|
|
203
157
|
* Commands a project declares for itself, in `.conduit/verification` — one per line, `#` comments
|
|
@@ -227,12 +181,17 @@ export function parseDeclaredVerification(text) {
|
|
|
227
181
|
}
|
|
228
182
|
return declared;
|
|
229
183
|
}
|
|
230
|
-
export async function
|
|
231
|
-
const commands = [];
|
|
184
|
+
export async function readDeclaredVerification(workspace) {
|
|
232
185
|
try {
|
|
233
|
-
|
|
186
|
+
return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
|
|
234
187
|
}
|
|
235
|
-
catch {
|
|
188
|
+
catch {
|
|
189
|
+
return []; // no declaration, or unreadable: discovery still applies
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export async function discoverVerificationCommands(workspace, files) {
|
|
193
|
+
// Declared first: the order is a preference order for every reader that takes one command.
|
|
194
|
+
const commands = await readDeclaredVerification(workspace);
|
|
236
195
|
if (files.has("package.json")) {
|
|
237
196
|
try {
|
|
238
197
|
const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
|
package/dist/checkout.js
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname } from "node:path";
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
14
|
+
import { gitOriginUrl } from "./git.js";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
async function pathExists(path, accessFn) {
|
|
16
17
|
try {
|
|
@@ -21,19 +22,6 @@ async function pathExists(path, accessFn) {
|
|
|
21
22
|
return false;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
async function gitOriginUrl(workspace, exec) {
|
|
25
|
-
try {
|
|
26
|
-
const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
|
|
27
|
-
timeout: 15_000,
|
|
28
|
-
maxBuffer: 1_000_000,
|
|
29
|
-
});
|
|
30
|
-
const value = stdout.trim();
|
|
31
|
-
return value.length > 0 ? value : null;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
25
|
/** A control-plane repository fingerprint needs a transport before Git can clone it. */
|
|
38
26
|
function cloneRepositoryUrls(repositoryUrl) {
|
|
39
27
|
const value = repositoryUrl.trim();
|
package/dist/cli.js
CHANGED
|
@@ -440,7 +440,10 @@ async function driversCommand() {
|
|
|
440
440
|
for (const lane of laneStatuses(config)) {
|
|
441
441
|
const when = lane.observed_at ? ` observed=${lane.observed_at}` : "";
|
|
442
442
|
const reset = lane.resets_at ? ` resets=${lane.resets_at}` : lane.allowance === "unavailable" ? " resets=unknown" : "";
|
|
443
|
-
|
|
443
|
+
// A degraded provider never refuses, so allowance stays "unknown" while every turn times out.
|
|
444
|
+
// Printing the count is the only way an operator sees why dispatch keeps avoiding this lane.
|
|
445
|
+
const slow = lane.timeouts ? ` timeouts=${lane.timeouts} (demoted)` : "";
|
|
446
|
+
console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset}${slow} (${lane.label})`);
|
|
444
447
|
}
|
|
445
448
|
const blocked = laneDispatchBlock(laneStatuses(config));
|
|
446
449
|
console.log(blocked
|
|
@@ -609,19 +612,22 @@ async function runner() {
|
|
|
609
612
|
progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
|
|
610
613
|
}
|
|
611
614
|
if (workspace && onlineDriverIds(config).length) {
|
|
615
|
+
// Read the workspace again, then publish it. The last brief stands if the read fails.
|
|
616
|
+
const publishWorkspaceState = async (options = {}) => {
|
|
617
|
+
const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
618
|
+
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, options);
|
|
619
|
+
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
620
|
+
return { preflight, hb };
|
|
621
|
+
};
|
|
612
622
|
progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
|
|
613
623
|
heartbeat: async () => {
|
|
614
|
-
|
|
615
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
|
|
616
|
-
await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
624
|
+
await publishWorkspaceState();
|
|
617
625
|
},
|
|
618
626
|
beforeClaim: async (driverId) => {
|
|
619
627
|
// Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
|
|
620
628
|
// Publish the fresh probe first; the Control Plane then remains the authority for the
|
|
621
629
|
// exact version frozen onto the claim response and event.
|
|
622
|
-
const
|
|
623
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, { force: true });
|
|
624
|
-
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
630
|
+
const { preflight, hb } = await publishWorkspaceState({ force: true });
|
|
625
631
|
if (hb.staleBridge)
|
|
626
632
|
return false;
|
|
627
633
|
const lane = preflight.drivers.find((candidate) => candidate.id === driverId);
|
package/dist/config.js
CHANGED
|
@@ -262,6 +262,23 @@ export async function saveConfigPrefs(config) {
|
|
|
262
262
|
* nothing without one.
|
|
263
263
|
*/
|
|
264
264
|
export async function saveDriverQuota(driverId, quota) {
|
|
265
|
+
await updateDriverLane(driverId, (lane) => {
|
|
266
|
+
if (quota)
|
|
267
|
+
lane.quota = quota;
|
|
268
|
+
else
|
|
269
|
+
delete lane.quota;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** Persist one lane's execution outcome without overwriting operator preferences or quota state. */
|
|
273
|
+
export async function saveDriverOutcome(driverId, outcome) {
|
|
274
|
+
await updateDriverLane(driverId, (lane) => {
|
|
275
|
+
if (outcome)
|
|
276
|
+
lane.outcome = outcome;
|
|
277
|
+
else
|
|
278
|
+
delete lane.outcome;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function updateDriverLane(driverId, update) {
|
|
265
282
|
const run = async () => {
|
|
266
283
|
await withConfigLock(async () => {
|
|
267
284
|
let prefs;
|
|
@@ -269,15 +286,12 @@ export async function saveDriverQuota(driverId, quota) {
|
|
|
269
286
|
prefs = JSON.parse(await readFile(path, "utf8"));
|
|
270
287
|
}
|
|
271
288
|
catch {
|
|
272
|
-
return; // No prefs on disk yet: nothing to attach
|
|
289
|
+
return; // No prefs on disk yet: nothing to attach lane state to.
|
|
273
290
|
}
|
|
274
291
|
const lane = prefs.drivers?.[driverId];
|
|
275
292
|
if (!lane || typeof lane !== "object")
|
|
276
293
|
return;
|
|
277
|
-
|
|
278
|
-
lane.quota = quota;
|
|
279
|
-
else
|
|
280
|
-
delete lane.quota;
|
|
294
|
+
update(lane);
|
|
281
295
|
await writeJsonAtomic(path, { ...prefs, activeAttempts: {}, sessions: undefined });
|
|
282
296
|
});
|
|
283
297
|
};
|
package/dist/drivers.js
CHANGED
|
@@ -55,10 +55,23 @@ export function normalizeDrivers(raw) {
|
|
|
55
55
|
const state = lane.state === "online" ? "online" : "offline";
|
|
56
56
|
const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
|
|
57
57
|
const quota = normalizeQuota(lane.quota);
|
|
58
|
-
|
|
58
|
+
const outcome = normalizeOutcome(lane.outcome);
|
|
59
|
+
out[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}), ...(outcome ? { outcome } : {}) };
|
|
59
60
|
}
|
|
60
61
|
return out;
|
|
61
62
|
}
|
|
63
|
+
/** Same rule as quota: a malformed outcome is dropped, never allowed to hold a lane back. */
|
|
64
|
+
function normalizeOutcome(raw) {
|
|
65
|
+
if (!raw || typeof raw !== "object")
|
|
66
|
+
return undefined;
|
|
67
|
+
const value = raw;
|
|
68
|
+
if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
|
|
69
|
+
return undefined;
|
|
70
|
+
if (!Number.isFinite(Date.parse(value.last_timeout_at)))
|
|
71
|
+
return undefined;
|
|
72
|
+
const count = Math.max(0, Math.floor(value.consecutive_timeouts));
|
|
73
|
+
return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
|
|
74
|
+
}
|
|
62
75
|
/** Drop a malformed quota rather than fail the whole config: a bad record must not dark a lane. */
|
|
63
76
|
function normalizeQuota(raw) {
|
|
64
77
|
if (!raw || typeof raw !== "object")
|
|
@@ -159,11 +172,14 @@ export function laneStatuses(config, now = Date.now()) {
|
|
|
159
172
|
const allowance = !local || !quota
|
|
160
173
|
? "unknown"
|
|
161
174
|
: quota.exhausted ? "unavailable" : "available";
|
|
175
|
+
const outcome = normalizeDrivers(config.drivers)[lane.id]?.outcome;
|
|
162
176
|
return {
|
|
163
177
|
...lane,
|
|
164
178
|
allowance,
|
|
165
179
|
observed_at: quota?.observed_at,
|
|
166
180
|
resets_at: quota?.resets_at ?? undefined,
|
|
181
|
+
...(laneTimedOutRecently(config, lane.id, now) ? { timeouts: outcome?.consecutive_timeouts } : {}),
|
|
182
|
+
// Timeouts demote but never make a lane ineligible: see pickDriverForClaim.
|
|
167
183
|
eligible: lane.state === "online" && allowance !== "unavailable",
|
|
168
184
|
};
|
|
169
185
|
});
|
|
@@ -178,6 +194,53 @@ export function laneDispatchBlock(statuses) {
|
|
|
178
194
|
return "every lane is offline";
|
|
179
195
|
return "every online lane has an allowance the provider refused";
|
|
180
196
|
}
|
|
197
|
+
/** Timeouts in a row before a lane stops being preferred. One is noise; two is a pattern. */
|
|
198
|
+
export const TIMEOUT_SUPPRESSION_THRESHOLD = 2;
|
|
199
|
+
/** How long a timeout pattern is believed. Short, because the cause is often the budget, not the lane. */
|
|
200
|
+
export const TIMEOUT_SUPPRESSION_MS = 60 * 60_000;
|
|
201
|
+
/** Record a turn that ended at the execution ceiling. Never touches the quota record. */
|
|
202
|
+
export function recordDriverTimeout(config, driverId, now = Date.now()) {
|
|
203
|
+
if (!isSupportedDriverId(driverId))
|
|
204
|
+
return config;
|
|
205
|
+
const drivers = normalizeDrivers(config.drivers);
|
|
206
|
+
const lane = drivers[driverId] ?? { state: "offline" };
|
|
207
|
+
const previous = lane.outcome?.consecutive_timeouts ?? 0;
|
|
208
|
+
drivers[driverId] = {
|
|
209
|
+
...lane,
|
|
210
|
+
outcome: { consecutive_timeouts: previous + 1, last_timeout_at: new Date(now).toISOString() },
|
|
211
|
+
};
|
|
212
|
+
return { ...config, drivers };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Clear what the last runs said about this lane.
|
|
216
|
+
*
|
|
217
|
+
* A run that finished is the strongest evidence there is, and it is worth more than any older claim
|
|
218
|
+
* — a lane that just worked is not spent and is not slow.
|
|
219
|
+
*/
|
|
220
|
+
export function clearDriverOutcome(config, driverId) {
|
|
221
|
+
if (!isSupportedDriverId(driverId))
|
|
222
|
+
return config;
|
|
223
|
+
const drivers = normalizeDrivers(config.drivers);
|
|
224
|
+
const lane = drivers[driverId];
|
|
225
|
+
if (!lane?.outcome)
|
|
226
|
+
return config;
|
|
227
|
+
const { outcome: _cleared, ...rest } = lane;
|
|
228
|
+
drivers[driverId] = rest;
|
|
229
|
+
return { ...config, drivers };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Whether this lane's recent timeouts should push it behind others.
|
|
233
|
+
*
|
|
234
|
+
* Expires on its own, because a timeout usually means the budget was too small for the work rather
|
|
235
|
+
* than that the lane is bad, and the next package may be smaller.
|
|
236
|
+
*/
|
|
237
|
+
export function laneTimedOutRecently(config, driverId, now = Date.now()) {
|
|
238
|
+
const outcome = normalizeDrivers(config.drivers)[driverId]?.outcome;
|
|
239
|
+
if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
|
|
240
|
+
return false;
|
|
241
|
+
const seen = Date.parse(outcome.last_timeout_at);
|
|
242
|
+
return now >= seen && now - seen <= TIMEOUT_SUPPRESSION_MS;
|
|
243
|
+
}
|
|
181
244
|
export function onlineDriverIds(config) {
|
|
182
245
|
const drivers = normalizeDrivers(config.drivers);
|
|
183
246
|
return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
|
|
@@ -290,14 +353,17 @@ export function pickDriverForClaim(config, processOnlineIds, eligible = () => tr
|
|
|
290
353
|
load.set(active.driverId, (load.get(active.driverId) ?? 0) + 1);
|
|
291
354
|
}
|
|
292
355
|
}
|
|
356
|
+
// Demote, never exclude. A lane held out of selection can never run the work that would prove it
|
|
357
|
+
// well again, and suppressing the last candidate would idle the machine over a claim about the
|
|
358
|
+
// past. Ordering is enough: a healthy lane wins while one exists, and a suppressed lane is still
|
|
359
|
+
// picked when it is all there is.
|
|
360
|
+
const rank = (id) => (laneTimedOutRecently(config, id) ? 1 : 0);
|
|
293
361
|
let best = online[0];
|
|
294
|
-
let bestLoad = load.get(best) ?? 0;
|
|
295
362
|
for (const id of online.slice(1)) {
|
|
296
|
-
const
|
|
297
|
-
|
|
363
|
+
const better = rank(id) < rank(best)
|
|
364
|
+
|| (rank(id) === rank(best) && (load.get(id) ?? 0) < (load.get(best) ?? 0));
|
|
365
|
+
if (better)
|
|
298
366
|
best = id;
|
|
299
|
-
bestLoad = n;
|
|
300
|
-
}
|
|
301
367
|
}
|
|
302
368
|
return best;
|
|
303
369
|
}
|
|
@@ -4,18 +4,9 @@
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
7
|
+
import { readHeadCommit } from "./git.js";
|
|
7
8
|
import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
async function workspaceHeadCommit(workspace) {
|
|
10
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
11
|
-
timeout: 30_000,
|
|
12
|
-
maxBuffer: 1_000_000,
|
|
13
|
-
});
|
|
14
|
-
const head = stdout.trim();
|
|
15
|
-
if (!head)
|
|
16
|
-
throw new Error("Could not read workspace HEAD");
|
|
17
|
-
return head;
|
|
18
|
-
}
|
|
19
10
|
async function uncommittedPaths(workspace) {
|
|
20
11
|
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
21
12
|
timeout: 30_000,
|
|
@@ -64,7 +55,7 @@ export async function ensureLandCommit(input) {
|
|
|
64
55
|
if (!base)
|
|
65
56
|
return { kind: "skipped" };
|
|
66
57
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
67
|
-
const readHead = input.readHeadCommit ??
|
|
58
|
+
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
68
59
|
const listPending = input.listUncommittedPaths ?? uncommittedPaths;
|
|
69
60
|
const commitPaths = input.commitPaths ?? commitScopedPaths;
|
|
70
61
|
const ahead = await countAhead(input.workspace, base);
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
9
|
+
import { readHeadCommit } from "./git.js";
|
|
10
|
+
import { forgeTransportFailure } from "./transport-fault.js";
|
|
9
11
|
import { agentNoLandCommitMessage } from "./land-contract.js";
|
|
10
12
|
const execFileAsync = promisify(execFile);
|
|
11
|
-
/** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
|
|
12
|
-
const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
13
13
|
/**
|
|
14
14
|
* Commits the attempt branch has and the base does not.
|
|
15
15
|
* Returns null when this checkout does not know the base. An unverifiable precondition must not
|
|
@@ -103,16 +103,6 @@ export function adoptPullRequestUrlFromEvidence(report, repositoryFingerprint) {
|
|
|
103
103
|
}
|
|
104
104
|
return report;
|
|
105
105
|
}
|
|
106
|
-
async function workspaceHeadCommit(workspace) {
|
|
107
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
108
|
-
timeout: 30_000,
|
|
109
|
-
maxBuffer: 1_000_000,
|
|
110
|
-
});
|
|
111
|
-
const head = stdout.trim().toLowerCase();
|
|
112
|
-
if (!/^[0-9a-f]{7,64}$/.test(head))
|
|
113
|
-
throw new Error("Could not determine workspace HEAD commit");
|
|
114
|
-
return head;
|
|
115
|
-
}
|
|
116
106
|
function execErrorMessage(error) {
|
|
117
107
|
if (!(error instanceof Error))
|
|
118
108
|
return String(error);
|
|
@@ -157,7 +147,7 @@ export async function pushOriginHead(workspace, options = {}) {
|
|
|
157
147
|
catch {
|
|
158
148
|
// fall through to retry / throw
|
|
159
149
|
}
|
|
160
|
-
if (attempt < attempts &&
|
|
150
|
+
if (attempt < attempts && forgeTransportFailure(lastMessage)) {
|
|
161
151
|
await sleep(750 * attempt);
|
|
162
152
|
continue;
|
|
163
153
|
}
|
|
@@ -179,7 +169,7 @@ export async function pushOriginHead(workspace, options = {}) {
|
|
|
179
169
|
* Agent-supplied PR URLs skip creation but still require HEAD identity.
|
|
180
170
|
*/
|
|
181
171
|
export async function ensureDeliveryPullRequest(input) {
|
|
182
|
-
const readHead = input.readHeadCommit ??
|
|
172
|
+
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
183
173
|
const push = input.pushOriginHead ?? pushOriginHead;
|
|
184
174
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
185
175
|
let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
-
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
8
|
+
import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
11
11
|
/** Cold `cargo test` in an attempt worktree routinely exceeds two minutes of compile. */
|
|
@@ -23,22 +23,53 @@ export function needsTestEvidence(_report, spec, grants) {
|
|
|
23
23
|
* is not satisfied by a silent `npm run typecheck`.
|
|
24
24
|
*/
|
|
25
25
|
export function isPreferentialTestCommand(command) {
|
|
26
|
-
return /^(npm run test|pnpm test|yarn test|cargo test|go test(?: \.\/\.\.\.)?|make test|
|
|
26
|
+
return /^(npm run test|pnpm test|yarn test|cargo test|go test(?: \.\/\.\.\.)?|make test|python3? -m (?:pytest|unittest)(?: [\w./=:-]+)*|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command.trim());
|
|
27
27
|
}
|
|
28
28
|
export function pickVerificationCommand(commands) {
|
|
29
29
|
const bounded = commands
|
|
30
30
|
.map((command) => command.trim())
|
|
31
|
-
.filter((command) => command &&
|
|
31
|
+
.filter((command) => command && isRunnableVerificationCommand(command));
|
|
32
32
|
return bounded.find(isPreferentialTestCommand) ?? bounded[0] ?? null;
|
|
33
33
|
}
|
|
34
34
|
/** Prefer one project-defined aggregate; otherwise witness every distinct bounded command. */
|
|
35
35
|
export function verificationEvidenceCommands(commands) {
|
|
36
36
|
const bounded = [...new Set(commands
|
|
37
37
|
.map((command) => command.trim())
|
|
38
|
-
.filter((command) => command &&
|
|
38
|
+
.filter((command) => command && isRunnableVerificationCommand(command)))];
|
|
39
39
|
const aggregate = bounded.find((command) => /^(?:npm run|pnpm|yarn) verify$|^make check$/.test(command));
|
|
40
40
|
return aggregate ? [aggregate] : bounded;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Bounded verification commands named inside free text. Mirror of the control plane's
|
|
44
|
+
* `verificationCommandsMentioned` in src/conductor/verification-capability.ts — Bridge cannot import
|
|
45
|
+
* server code, and test/planner-capability.test.ts pins the two together.
|
|
46
|
+
*/
|
|
47
|
+
export function verificationCommandsMentioned(text) {
|
|
48
|
+
const pattern = /(?:^|[\s("'`])((?:npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (?:test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|python3? -m [A-Za-z0-9_][\w.]*(?: [\w./=:-]*[./=-][\w./=:-]*)*|pytest(?: [\w./=-]+)?|python3? [\w./-]+\.py(?: [\w./=:-]*[./=-][\w./=:-]*)*|\.\/gradlew test))(?=$|[\s"'`),.;:])/g;
|
|
49
|
+
return [...new Set([...text.slice(0, 20_000).matchAll(pattern)].map((match) => match[1]).filter(isBoundedVerificationCommand))];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Does a discovered or declared command authorize this one? The agent receives `Bash(<command>:*)`,
|
|
53
|
+
* so a command authorizes itself with arguments. Mirror of the control plane's
|
|
54
|
+
* `verificationCommandIsAuthorized`.
|
|
55
|
+
*/
|
|
56
|
+
export function verificationCommandIsAuthorized(command, discovered) {
|
|
57
|
+
return discovered.some((offered) => command === offered || command.startsWith(`${offered} `));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The commands the acceptance criteria name, when the workspace authorizes them.
|
|
61
|
+
*
|
|
62
|
+
* A criterion that says `python3 tools/mutate.py --mutants …/mutants.json reports every mutant killed`
|
|
63
|
+
* names its own witness. Witnessing only the aggregate gate left that command agent-attested, the
|
|
64
|
+
* reviewer could not treat the criterion as supported, and a delivery whose code was correct still
|
|
65
|
+
* landed in Needs you instead of Ready. A named command runs only when a discovered or declared
|
|
66
|
+
* command is its prefix: the criterion cannot widen the shell the grant already bounds.
|
|
67
|
+
*/
|
|
68
|
+
export function acceptanceVerificationCommands(acceptance, discovered) {
|
|
69
|
+
const authorized = discovered.map((command) => command.trim()).filter(Boolean);
|
|
70
|
+
return verificationCommandsMentioned(acceptance.join("\n"))
|
|
71
|
+
.filter((command) => isRunnableVerificationCommand(command) && verificationCommandIsAuthorized(command, authorized));
|
|
72
|
+
}
|
|
42
73
|
function argvForBoundedCommand(command) {
|
|
43
74
|
return command.trim().split(/\s+/);
|
|
44
75
|
}
|
|
@@ -62,13 +93,21 @@ function argvForBoundedCommand(command) {
|
|
|
62
93
|
export function diagnosisVerificationCommands(commands) {
|
|
63
94
|
const bounded = commands
|
|
64
95
|
.map((command) => command.trim())
|
|
65
|
-
.filter((command) => command &&
|
|
96
|
+
.filter((command) => command && isRunnableVerificationCommand(command));
|
|
66
97
|
const nonTest = bounded.filter((command) => !isPreferentialTestCommand(command));
|
|
67
98
|
const tests = bounded.filter((command) => isPreferentialTestCommand(command));
|
|
68
99
|
return [...nonTest, ...tests];
|
|
69
100
|
}
|
|
70
101
|
export async function captureVerificationFailure(input) {
|
|
71
|
-
|
|
102
|
+
// A declared prefix such as `python3 tools/mutate.py` is authority, not a gate: run bare it only
|
|
103
|
+
// prints usage and exits non-zero, which would be reported as the failure. When a criterion names
|
|
104
|
+
// the full command, that command is the gate and the bare prefix is not run.
|
|
105
|
+
const named = acceptanceVerificationCommands(input.acceptance ?? [], input.verificationCommands);
|
|
106
|
+
const commands = [...new Set([
|
|
107
|
+
...diagnosisVerificationCommands(input.verificationCommands)
|
|
108
|
+
.filter((command) => !named.some((full) => full !== command && full.startsWith(`${command} `))),
|
|
109
|
+
...named,
|
|
110
|
+
])];
|
|
72
111
|
if (commands.length === 0)
|
|
73
112
|
return null;
|
|
74
113
|
const run = input.runCommand ?? defaultRunCommand;
|
|
@@ -236,7 +275,7 @@ function verificationCommandNames(command) {
|
|
|
236
275
|
names.add(script);
|
|
237
276
|
return names;
|
|
238
277
|
}
|
|
239
|
-
export function criteriaForTestEvidence(report, command) {
|
|
278
|
+
export function criteriaForTestEvidence(report, command, acceptance = []) {
|
|
240
279
|
const testEvidence = report.evidence.filter((item) => item.kind === "test");
|
|
241
280
|
const commandNames = command ? verificationCommandNames(command) : null;
|
|
242
281
|
const sources = commandNames
|
|
@@ -252,6 +291,20 @@ export function criteriaForTestEvidence(report, command) {
|
|
|
252
291
|
mapped.add(criterion);
|
|
253
292
|
}
|
|
254
293
|
}
|
|
294
|
+
// A criterion that names the command verbatim is proved by that command's witness, whether or
|
|
295
|
+
// not the agent remembered to map it. Exact command text, followed by nothing that could extend
|
|
296
|
+
// it: `make check` must not claim a criterion about `make checkout`.
|
|
297
|
+
if (command) {
|
|
298
|
+
const exact = command.trim();
|
|
299
|
+
for (const criterion of acceptance) {
|
|
300
|
+
const at = criterion.indexOf(exact);
|
|
301
|
+
if (at < 0)
|
|
302
|
+
continue;
|
|
303
|
+
const next = criterion.charAt(at + exact.length);
|
|
304
|
+
if (next === "" || !/[\w./=:-]/.test(next))
|
|
305
|
+
mapped.add(criterion);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
255
308
|
return [...mapped];
|
|
256
309
|
}
|
|
257
310
|
/**
|
|
@@ -261,7 +314,13 @@ export function criteriaForTestEvidence(report, command) {
|
|
|
261
314
|
export async function ensureTestEvidence(input) {
|
|
262
315
|
if (!needsTestEvidence(input.report, input.spec, input.grants))
|
|
263
316
|
return input.report;
|
|
264
|
-
const
|
|
317
|
+
const acceptance = input.spec.acceptance ?? [];
|
|
318
|
+
// The project's gate first, then every bounded command the criteria name that the workspace
|
|
319
|
+
// authorizes. Each is witnessed on its own, so the reviewer sees the criterion's own proof.
|
|
320
|
+
const commands = [...new Set([
|
|
321
|
+
...verificationEvidenceCommands(input.verificationCommands),
|
|
322
|
+
...acceptanceVerificationCommands(acceptance, input.verificationCommands),
|
|
323
|
+
])];
|
|
265
324
|
if (commands.length === 0) {
|
|
266
325
|
throw new Error("Agent report is missing required evidence: test (no bounded verification command available for Bridge to run)");
|
|
267
326
|
}
|
|
@@ -307,7 +366,7 @@ export async function ensureTestEvidence(input) {
|
|
|
307
366
|
kind: "test",
|
|
308
367
|
name: command,
|
|
309
368
|
details,
|
|
310
|
-
acceptance_criteria: criteriaForTestEvidence(input.report, command),
|
|
369
|
+
acceptance_criteria: criteriaForTestEvidence(input.report, command, acceptance),
|
|
311
370
|
witness: "bridge",
|
|
312
371
|
});
|
|
313
372
|
}
|
package/dist/execution-class.js
CHANGED
|
@@ -10,7 +10,7 @@ export function isBoundedVerificationCommand(command) {
|
|
|
10
10
|
// project from naming its own gate, which is what forced four attempts to die on a command they
|
|
11
11
|
// could not run. The boundary that matters is the SHAPE: one tool, one identifier, no shell
|
|
12
12
|
// metacharacters, so nothing can be smuggled in through the name.
|
|
13
|
-
return /^(npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|
|
|
13
|
+
return /^(npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|python3? -m [A-Za-z0-9_][\w.]*(?: [\w./=:-]+)*|pytest(?: [\w./=-]+)?|python3? [\w./-]+\.py(?: [\w./=:-]+)*|\.\/gradlew test)$/.test(command);
|
|
14
14
|
}
|
|
15
15
|
/** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
|
|
16
16
|
export const branchCreateCommands = [
|
|
@@ -25,6 +25,31 @@ export const deniedCommands = [
|
|
|
25
25
|
];
|
|
26
26
|
/** Land commands denied for publish_artifact / observe* (delivery gate still enforces). */
|
|
27
27
|
export const landCommands = ["git commit", "git push", "gh pr create"];
|
|
28
|
+
/** Script and target names that publish, whatever tool runs them. */
|
|
29
|
+
const DENIED_ACTION_PATTERN = /^(?:npm run|pnpm|yarn|make)\s+[\w.:-]*\b(?:deploy|publish|release|promote)\b/i;
|
|
30
|
+
/**
|
|
31
|
+
* A denied action, read the way permission is granted.
|
|
32
|
+
*
|
|
33
|
+
* `Bash(git push:*)` authorizes `git push` and its arguments, so a denial must cover the arguments
|
|
34
|
+
* too. Equality covered only the exact spelling: `pnpm deploy:prod` and `make deploy` were never on
|
|
35
|
+
* the list, and a project names its own gates now, so the spelling reaching a driver is the
|
|
36
|
+
* project's. The check therefore reads the action.
|
|
37
|
+
*/
|
|
38
|
+
export function isDeniedCommand(command) {
|
|
39
|
+
const text = command.trim();
|
|
40
|
+
return deniedCommands.some((denied) => text === denied || text.startsWith(`${denied} `))
|
|
41
|
+
|| DENIED_ACTION_PATTERN.test(text);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A command a `test_run` grant may run: bounded in shape, and not a denied action.
|
|
45
|
+
*
|
|
46
|
+
* Every driver projection asks this one question. Only the prompt used to remove denied commands, so
|
|
47
|
+
* `Bash(npm run deploy:*)` reached the agent while the prompt beside it said that command was
|
|
48
|
+
* forbidden.
|
|
49
|
+
*/
|
|
50
|
+
export function isRunnableVerificationCommand(command) {
|
|
51
|
+
return isBoundedVerificationCommand(command) && !isDeniedCommand(command);
|
|
52
|
+
}
|
|
28
53
|
export const EXECUTION_CLASSES = [
|
|
29
54
|
"observe",
|
|
30
55
|
"observe_network",
|
|
@@ -82,7 +107,7 @@ export function executionClassPromptRules(input) {
|
|
|
82
107
|
const rules = [];
|
|
83
108
|
const grantShell = [
|
|
84
109
|
...(grants.includes("test_run")
|
|
85
|
-
? verificationCommands.filter(
|
|
110
|
+
? verificationCommands.filter(isRunnableVerificationCommand)
|
|
86
111
|
: []),
|
|
87
112
|
...(grants.includes("branch_create") ? branchCreateCommands : []),
|
|
88
113
|
...(grants.includes("pr_create") ? prCreateCommands : []),
|
|
@@ -146,7 +171,7 @@ export function projectCursor(executionClass, input) {
|
|
|
146
171
|
allow.push("WebFetch(*)");
|
|
147
172
|
if (executionClass === "mutate_repo" || executionClass === "verify") {
|
|
148
173
|
if (grants.includes("test_run")) {
|
|
149
|
-
allow.push(...verificationCommands.filter(
|
|
174
|
+
allow.push(...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Shell(${command})`));
|
|
150
175
|
}
|
|
151
176
|
if (executionClass === "mutate_repo" && !input.diagnosis) {
|
|
152
177
|
if (grants.includes("branch_create"))
|
|
@@ -182,7 +207,7 @@ export function projectClaude(executionClass, input) {
|
|
|
182
207
|
return {
|
|
183
208
|
allowedTools: [
|
|
184
209
|
"Read", "Glob", "Grep",
|
|
185
|
-
...verificationCommands.filter(
|
|
210
|
+
...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Bash(${command}:*)`),
|
|
186
211
|
...fetchTools,
|
|
187
212
|
],
|
|
188
213
|
disallowedTools,
|
|
@@ -200,7 +225,7 @@ export function projectClaude(executionClass, input) {
|
|
|
200
225
|
return {
|
|
201
226
|
allowedTools: [
|
|
202
227
|
"Read",
|
|
203
|
-
...verificationCommands.filter(
|
|
228
|
+
...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Bash(${command}:*)`),
|
|
204
229
|
...fetchTools,
|
|
205
230
|
],
|
|
206
231
|
disallowedTools,
|
|
@@ -214,7 +239,7 @@ export function projectClaude(executionClass, input) {
|
|
|
214
239
|
if (grants.includes("repo_write"))
|
|
215
240
|
tools.push("Edit", "Write");
|
|
216
241
|
if (grants.includes("test_run")) {
|
|
217
|
-
tools.push(...verificationCommands.filter(
|
|
242
|
+
tools.push(...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Bash(${command}:*)`));
|
|
218
243
|
}
|
|
219
244
|
if (grants.includes("branch_create"))
|
|
220
245
|
tools.push(...branchCreateCommands.map((command) => `Bash(${command}:*)`));
|
|
@@ -272,12 +297,12 @@ export function projectGrok(executionClass, input) {
|
|
|
272
297
|
return { sandbox: "read-only", allow, deny, disableWebSearch: !fetch, disallowedTools };
|
|
273
298
|
}
|
|
274
299
|
if (input.diagnosis) {
|
|
275
|
-
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(
|
|
300
|
+
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
|
|
276
301
|
deny.push("Edit(*)", "Write(*)");
|
|
277
302
|
return { sandbox: "read-only", allow, deny, disableWebSearch: !fetch, disallowedTools };
|
|
278
303
|
}
|
|
279
304
|
if (executionClass === "verify") {
|
|
280
|
-
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(
|
|
305
|
+
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
|
|
281
306
|
deny.push("Edit(*)", "Write(*)");
|
|
282
307
|
return { sandbox: "workspace", allow, deny, disableWebSearch: !fetch, disallowedTools };
|
|
283
308
|
}
|
|
@@ -290,7 +315,7 @@ export function projectGrok(executionClass, input) {
|
|
|
290
315
|
if (grants.includes("repo_write"))
|
|
291
316
|
allow.push("Edit(*)", "Write(*)");
|
|
292
317
|
if (grants.includes("test_run")) {
|
|
293
|
-
allow.push(...verificationCommands.filter(
|
|
318
|
+
allow.push(...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
|
|
294
319
|
}
|
|
295
320
|
if (grants.includes("branch_create"))
|
|
296
321
|
allow.push(...branchCreateCommands.map(grokBash));
|
package/dist/execution-facts.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
7
|
+
import { commitOrNull } from "./git.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
export const EXECUTION_CLASSES = ["observe", "observe_network", "publish_artifact", "verify", "mutate_repo"];
|
|
9
10
|
export const BOOTSTRAP_RESULTS = ["not_applicable", "not_run", "installed", "failed"];
|
|
@@ -91,22 +92,6 @@ export async function workspaceIsClean(workspace) {
|
|
|
91
92
|
return false;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
|
-
/** Read only the bounded HEAD identity retained when an attempt terminates. */
|
|
95
|
-
export async function finalCommitForWorkspace(workspace) {
|
|
96
|
-
try {
|
|
97
|
-
const result = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
98
|
-
timeout: 15_000,
|
|
99
|
-
windowsHide: true,
|
|
100
|
-
});
|
|
101
|
-
return commitOrNull(result.stdout.trim());
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
function commitOrNull(value) {
|
|
108
|
-
return value && /^[0-9a-f]{40,64}$/i.test(value) ? value.toLowerCase() : null;
|
|
109
|
-
}
|
|
110
95
|
/** Build one self-contained heartbeat/attempt snapshot from existing Bridge observations. */
|
|
111
96
|
export async function buildSovereignExecutionFacts(input) {
|
|
112
97
|
const repository = input.brief?.repository?.trim();
|
package/dist/execution.js
CHANGED
|
@@ -3,15 +3,17 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
5
5
|
import { ConduitRequestError } from "./client.js";
|
|
6
|
-
import { redactSecrets, saveDriverQuota } from "./config.js";
|
|
6
|
+
import { redactSecrets, saveDriverOutcome, saveDriverQuota } from "./config.js";
|
|
7
7
|
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
8
8
|
import { assertClassFloor } from "./execution-class.js";
|
|
9
|
-
import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis } from "./drivers.js";
|
|
9
|
+
import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
|
|
10
10
|
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
14
|
-
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace,
|
|
14
|
+
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, workspaceIsClean } from "./execution-facts.js";
|
|
15
|
+
import { forgeTransportFailure } from "./transport-fault.js";
|
|
16
|
+
import { headCommitOrNull } from "./git.js";
|
|
15
17
|
import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
|
|
16
18
|
import { bridgeVersion } from "./version.js";
|
|
17
19
|
import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
@@ -63,15 +65,6 @@ function changesRequestedFeedback(summary) {
|
|
|
63
65
|
return null;
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
|
-
/**
|
|
67
|
-
* A network fault reaching the code host. Mirrors FORGE_TRANSPORT_PATTERN in the control plane's
|
|
68
|
-
* failures.ts — Bridge cannot import from there, so test/failure-classification.test.ts pins the two
|
|
69
|
-
* together rather than trusting them to stay in step.
|
|
70
|
-
*/
|
|
71
|
-
export const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
72
|
-
export function forgeTransportFailure(message) {
|
|
73
|
-
return FORGE_TRANSPORT_PATTERN.test(message);
|
|
74
|
-
}
|
|
75
68
|
/**
|
|
76
69
|
* A vendor refusing a local-fuel run because the operator's subscription window is spent.
|
|
77
70
|
*
|
|
@@ -231,14 +224,36 @@ export function classifyFinalizeFailure(message) {
|
|
|
231
224
|
* leave the lane eligible.
|
|
232
225
|
*/
|
|
233
226
|
export async function learnDriverFuel(config, driverId, fuelSource, result) {
|
|
234
|
-
|
|
227
|
+
// Fuel is what the vendor said; outcome is what the run did. Both are learned here so every path
|
|
228
|
+
// that already reports a result learns both, and no new call site can forget one of them.
|
|
229
|
+
let next = observeDriverFuel(config, driverId, fuelSource, result);
|
|
230
|
+
next = observeDriverOutcome(next, driverId, result);
|
|
231
|
+
const drivers = next.drivers;
|
|
235
232
|
if (JSON.stringify(drivers ?? {}) === JSON.stringify(config.drivers ?? {}))
|
|
236
233
|
return;
|
|
237
234
|
config.drivers = drivers;
|
|
238
235
|
// Only this lane's quota reaches disk. Writing the whole snapshot would carry the rest of a config
|
|
239
236
|
// that may be minutes stale back over the operator's newer choices — see saveDriverQuota.
|
|
240
237
|
// A fuel record is worth less than the run it came from: never let a disk fault fail the attempt.
|
|
241
|
-
await
|
|
238
|
+
await Promise.all([
|
|
239
|
+
saveDriverQuota(driverId, drivers?.[driverId]?.quota),
|
|
240
|
+
saveDriverOutcome(driverId, drivers?.[driverId]?.outcome),
|
|
241
|
+
]).catch(() => undefined);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Learn from what the run did, not from what a vendor said about it.
|
|
245
|
+
*
|
|
246
|
+
* A provider that degrades instead of refusing — a spent Cursor subscription still answering from a
|
|
247
|
+
* slow free tier — produces no refusal to learn from, so the quota record stays empty and the lane
|
|
248
|
+
* looks healthy while every turn runs to the ceiling. The timeout itself is the evidence.
|
|
249
|
+
*
|
|
250
|
+
* Recorded separately from quota on purpose. A timeout means the budget was too small or the lane is
|
|
251
|
+
* degraded; a refusal means the allowance is spent. They call for different operator actions.
|
|
252
|
+
*/
|
|
253
|
+
export function observeDriverOutcome(config, driverId, result) {
|
|
254
|
+
if (result.status !== "failed")
|
|
255
|
+
return clearDriverOutcome(config, driverId);
|
|
256
|
+
return agentTurnTimedOut(result.error ?? "") ? recordDriverTimeout(config, driverId) : config;
|
|
242
257
|
}
|
|
243
258
|
export function observeDriverFuel(config, driverId, fuelSource, result, now = Date.now()) {
|
|
244
259
|
if (fuelSource !== "local")
|
|
@@ -680,7 +695,8 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
|
|
|
680
695
|
for (const attemptId of releasedWorktrees) {
|
|
681
696
|
const worktree = attemptWorktreePath(workspace, attemptId);
|
|
682
697
|
await removeAttemptWorktree(workspace, worktree)
|
|
683
|
-
.then(() =>
|
|
698
|
+
.then((released) => { if (released)
|
|
699
|
+
console.log(`Released settled diagnostic worktree ${worktree}`); })
|
|
684
700
|
.catch((error) => console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
|
|
685
701
|
}
|
|
686
702
|
const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
|
|
@@ -1219,6 +1235,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1219
1235
|
? await captureVerificationFailure({
|
|
1220
1236
|
workspace: attemptWorkspace,
|
|
1221
1237
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
1238
|
+
acceptance: spec.acceptance ?? [],
|
|
1222
1239
|
})
|
|
1223
1240
|
: null;
|
|
1224
1241
|
const message = verificationDetail
|
|
@@ -1395,7 +1412,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1395
1412
|
}
|
|
1396
1413
|
catch (repairError) {
|
|
1397
1414
|
const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
|
|
1398
|
-
|
|
1415
|
+
const detail = `Delivery report repair failed: ${message}`;
|
|
1416
|
+
const retryable = retryableAgentFailure(message);
|
|
1417
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: detail, retryable, failure: retryable ? undefined : deliveryReportRepairFailure(detail), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
1399
1418
|
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
1400
1419
|
return;
|
|
1401
1420
|
}
|
|
@@ -1407,7 +1426,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1407
1426
|
}
|
|
1408
1427
|
if (repaired.status === "failed") {
|
|
1409
1428
|
const message = repaired.error ?? "Delivery report repair failed";
|
|
1410
|
-
|
|
1429
|
+
const retryable = retryableAgentFailure(message);
|
|
1430
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable, failure: retryable ? undefined : deliveryReportRepairFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
1411
1431
|
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
1412
1432
|
return;
|
|
1413
1433
|
}
|
|
@@ -1429,15 +1449,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1429
1449
|
body: {
|
|
1430
1450
|
error: detail,
|
|
1431
1451
|
retryable: false,
|
|
1432
|
-
failure:
|
|
1433
|
-
code: "delivery_report_invalid",
|
|
1434
|
-
class: "contract",
|
|
1435
|
-
disposition: "rework",
|
|
1436
|
-
responsible_party: "conduit",
|
|
1437
|
-
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
1438
|
-
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
1439
|
-
diagnostic_detail: detail,
|
|
1440
|
-
},
|
|
1452
|
+
failure: deliveryReportRepairFailure(detail),
|
|
1441
1453
|
idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
|
|
1442
1454
|
},
|
|
1443
1455
|
});
|
|
@@ -2071,11 +2083,29 @@ function retainDiagnosticWorktree(response) {
|
|
|
2071
2083
|
// receive that decision, so fail safe by retaining the only copy of the failed code.
|
|
2072
2084
|
return response.retain_worktree === true || response.status === "invalid_lease";
|
|
2073
2085
|
}
|
|
2086
|
+
/**
|
|
2087
|
+
* The envelope for a delivery report the Bridge could not repair.
|
|
2088
|
+
*
|
|
2089
|
+
* The implementation finished; only the response envelope is unusable. The control plane must be
|
|
2090
|
+
* able to tell that from the code alone -- it used to read the failure text, and prose is not an
|
|
2091
|
+
* authority. A retryable fault is not this: it gets another attempt rather than repair guidance.
|
|
2092
|
+
*/
|
|
2093
|
+
function deliveryReportRepairFailure(detail) {
|
|
2094
|
+
return {
|
|
2095
|
+
code: "delivery_report_invalid",
|
|
2096
|
+
class: "contract",
|
|
2097
|
+
disposition: "rework",
|
|
2098
|
+
responsible_party: "conduit",
|
|
2099
|
+
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
2100
|
+
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
2101
|
+
diagnostic_detail: detail,
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2074
2104
|
async function queueTerminal(client, taskId, terminal) {
|
|
2075
2105
|
const active = client.attempt(taskId);
|
|
2076
2106
|
let executionFacts = active.executionFacts;
|
|
2077
2107
|
if (executionFacts && executionFacts.final_commit === null && active.worktreePath) {
|
|
2078
|
-
const finalCommit = await
|
|
2108
|
+
const finalCommit = await headCommitOrNull(active.worktreePath);
|
|
2079
2109
|
if (finalCommit) {
|
|
2080
2110
|
executionFacts = { ...executionFacts, final_commit: finalCommit };
|
|
2081
2111
|
await client.updateAttempt(taskId, { executionFacts });
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every read of Git identity in a workspace — HEAD and the origin URL.
|
|
3
|
+
* One shape of commit identity, so the control plane never receives two spellings
|
|
4
|
+
* of the same object name from two callers.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
/** A full object name, lowercased, or null when the text is not one. */
|
|
10
|
+
export function commitOrNull(value) {
|
|
11
|
+
const text = value?.trim() ?? "";
|
|
12
|
+
return /^[0-9a-f]{40,64}$/i.test(text) ? text.toLowerCase() : null;
|
|
13
|
+
}
|
|
14
|
+
/** The workspace HEAD, or null when the directory is not a readable repository. */
|
|
15
|
+
export async function headCommitOrNull(workspace) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
18
|
+
timeout: 30_000,
|
|
19
|
+
maxBuffer: 1_000_000,
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
});
|
|
22
|
+
return commitOrNull(stdout);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The workspace HEAD, where a missing HEAD must end the step instead of being reported as absent. */
|
|
29
|
+
export async function readHeadCommit(workspace) {
|
|
30
|
+
const head = await headCommitOrNull(workspace);
|
|
31
|
+
if (!head)
|
|
32
|
+
throw new Error("Could not determine workspace HEAD commit");
|
|
33
|
+
return head;
|
|
34
|
+
}
|
|
35
|
+
/** The configured origin, or null when the workspace has no origin remote. */
|
|
36
|
+
export async function gitOriginUrl(workspace, exec = execFileAsync) {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
|
|
39
|
+
timeout: 15_000,
|
|
40
|
+
maxBuffer: 1_000_000,
|
|
41
|
+
});
|
|
42
|
+
const value = stdout.trim();
|
|
43
|
+
return value.length > 0 ? value : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/investigation.js
CHANGED
|
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { boundedTail } from "./ensure-test-evidence.js";
|
|
10
10
|
import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
|
|
11
11
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
|
|
12
|
+
import { headCommitOrNull } from "./git.js";
|
|
12
13
|
import { redactSecrets } from "./config.js";
|
|
13
14
|
import { learnDriverFuel } from "./execution.js";
|
|
14
15
|
import { DRIVERS, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
@@ -93,9 +94,11 @@ export function parseInvestigationBrief(text) {
|
|
|
93
94
|
*/
|
|
94
95
|
async function workspaceFingerprint(workspace) {
|
|
95
96
|
try {
|
|
96
|
-
const head = await
|
|
97
|
+
const head = await headCommitOrNull(workspace);
|
|
98
|
+
if (!head)
|
|
99
|
+
return null;
|
|
97
100
|
const dirty = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], { timeout: 30_000, maxBuffer: 8_000_000 });
|
|
98
|
-
return `${head
|
|
101
|
+
return `${head}\n${dirty.stdout.trim()}`;
|
|
99
102
|
}
|
|
100
103
|
catch {
|
|
101
104
|
return null;
|
package/dist/normative-refs.js
CHANGED
|
@@ -11,6 +11,7 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
14
|
+
import { headCommitOrNull } from "./git.js";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
export const normativeRefSchema = z.object({
|
|
16
17
|
label: z.string().trim().min(1).max(120),
|
|
@@ -68,18 +69,6 @@ async function pathExists(path) {
|
|
|
68
69
|
return false;
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
|
-
async function readPinnedRevision(repoDir) {
|
|
72
|
-
try {
|
|
73
|
-
const { stdout } = await execFileAsync("git", ["-C", repoDir, "rev-parse", "HEAD"], {
|
|
74
|
-
timeout: 30_000,
|
|
75
|
-
maxBuffer: 256_000,
|
|
76
|
-
});
|
|
77
|
-
return stdout.trim() || null;
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
72
|
async function gitNoPrompt(args, timeout, maxBuffer) {
|
|
84
73
|
await execFileAsync("git", args, { timeout, maxBuffer, env: GIT_NO_PROMPT });
|
|
85
74
|
}
|
|
@@ -127,7 +116,7 @@ async function ensureRepoCache(sourceWorkspace, ref) {
|
|
|
127
116
|
// Keep the last successful cache when origin is unreachable (offline tests, private host already rejected).
|
|
128
117
|
}
|
|
129
118
|
}
|
|
130
|
-
const revision = (await
|
|
119
|
+
const revision = (await headCommitOrNull(dir)) ?? wantedRevision;
|
|
131
120
|
await writeFile(marker, `${revision}\n`, "utf8");
|
|
132
121
|
return { dir, revision };
|
|
133
122
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A network fault reaching the code host. Mirrors FORGE_TRANSPORT_PATTERN in the control plane's
|
|
3
|
+
* failures.ts — Bridge cannot import from there, so test/failure-classification.test.ts pins the two
|
|
4
|
+
* together rather than trusting them to stay in step.
|
|
5
|
+
*
|
|
6
|
+
* It lives in its own module because both execution.ts and ensure-pull-request.ts read it, and
|
|
7
|
+
* execution.ts imports ensure-pull-request.ts.
|
|
8
|
+
*/
|
|
9
|
+
export const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
10
|
+
export function forgeTransportFailure(message) {
|
|
11
|
+
return FORGE_TRANSPORT_PATTERN.test(message);
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.32",
|
|
4
4
|
"description": "Conduit Bridge CLI \u2014 join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|