@miraland-labs/conduit-bridge 0.16.30 → 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.
@@ -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
- // Settled repair releases are returned on every assignment poll until the local tree is gone.
87
- // Make that at-least-once signal cheap and idempotent instead of invoking Git on a missing path.
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
- const { stdout } = await execFileAsync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
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, stat } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
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 gitRemoteUrl(workspace),
25
- base_commit: await gitHeadCommit(workspace),
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 discoverVerificationCommands(workspace, files) {
231
- const commands = [];
184
+ export async function readDeclaredVerification(workspace) {
232
185
  try {
233
- commands.push(...parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8")));
186
+ return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
234
187
  }
235
- catch { /* no declaration, or unreadable: discovery below still applies */ }
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
@@ -612,19 +612,22 @@ async function runner() {
612
612
  progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
613
613
  }
614
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
+ };
615
622
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
616
623
  heartbeat: async () => {
617
- const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
618
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
619
- await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
624
+ await publishWorkspaceState();
620
625
  },
621
626
  beforeClaim: async (driverId) => {
622
627
  // Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
623
628
  // Publish the fresh probe first; the Control Plane then remains the authority for the
624
629
  // exact version frozen onto the claim response and event.
625
- const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
626
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, { force: true });
627
- const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
630
+ const { preflight, hb } = await publishWorkspaceState({ force: true });
628
631
  if (hb.staleBridge)
629
632
  return false;
630
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 a quota to.
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
- if (quota)
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
@@ -67,6 +67,8 @@ function normalizeOutcome(raw) {
67
67
  const value = raw;
68
68
  if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
69
69
  return undefined;
70
+ if (!Number.isFinite(Date.parse(value.last_timeout_at)))
71
+ return undefined;
70
72
  const count = Math.max(0, Math.floor(value.consecutive_timeouts));
71
73
  return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
72
74
  }
@@ -237,7 +239,7 @@ export function laneTimedOutRecently(config, driverId, now = Date.now()) {
237
239
  if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
238
240
  return false;
239
241
  const seen = Date.parse(outcome.last_timeout_at);
240
- return !Number.isFinite(seen) || now - seen <= TIMEOUT_SUPPRESSION_MS;
242
+ return now >= seen && now - seen <= TIMEOUT_SUPPRESSION_MS;
241
243
  }
242
244
  export function onlineDriverIds(config) {
243
245
  const drivers = normalizeDrivers(config.drivers);
@@ -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 ?? workspaceHeadCommit;
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 && FORGE_TRANSPORT_PATTERN.test(lastMessage)) {
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 ?? workspaceHeadCommit;
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|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command.trim());
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 && isBoundedVerificationCommand(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 && isBoundedVerificationCommand(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 && isBoundedVerificationCommand(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
- const commands = diagnosisVerificationCommands(input.verificationCommands);
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 commands = verificationEvidenceCommands(input.verificationCommands);
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
  }
@@ -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._-]*|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command);
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((command) => isBoundedVerificationCommand(command) && !deniedCommands.includes(command))
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(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
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(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
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(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
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(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
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(isBoundedVerificationCommand).map(grokBash));
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(isBoundedVerificationCommand).map(grokBash));
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(isBoundedVerificationCommand).map(grokBash));
318
+ allow.push(...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
294
319
  }
295
320
  if (grants.includes("branch_create"))
296
321
  allow.push(...branchCreateCommands.map(grokBash));
@@ -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,7 +3,7 @@ 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
9
  import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
@@ -11,7 +11,9 @@ import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quaran
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, finalCommitForWorkspace, workspaceIsClean } from "./execution-facts.js";
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
  *
@@ -242,7 +235,10 @@ export async function learnDriverFuel(config, driverId, fuelSource, result) {
242
235
  // Only this lane's quota reaches disk. Writing the whole snapshot would carry the rest of a config
243
236
  // that may be minutes stale back over the operator's newer choices — see saveDriverQuota.
244
237
  // A fuel record is worth less than the run it came from: never let a disk fault fail the attempt.
245
- await saveDriverQuota(driverId, drivers?.[driverId]?.quota).catch(() => undefined);
238
+ await Promise.all([
239
+ saveDriverQuota(driverId, drivers?.[driverId]?.quota),
240
+ saveDriverOutcome(driverId, drivers?.[driverId]?.outcome),
241
+ ]).catch(() => undefined);
246
242
  }
247
243
  /**
248
244
  * Learn from what the run did, not from what a vendor said about it.
@@ -699,7 +695,8 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
699
695
  for (const attemptId of releasedWorktrees) {
700
696
  const worktree = attemptWorktreePath(workspace, attemptId);
701
697
  await removeAttemptWorktree(workspace, worktree)
702
- .then(() => console.log(`Released settled diagnostic worktree ${worktree}`))
698
+ .then((released) => { if (released)
699
+ console.log(`Released settled diagnostic worktree ${worktree}`); })
703
700
  .catch((error) => console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
704
701
  }
705
702
  const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
@@ -1238,6 +1235,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1238
1235
  ? await captureVerificationFailure({
1239
1236
  workspace: attemptWorkspace,
1240
1237
  verificationCommands: attemptBrief?.verification ?? [],
1238
+ acceptance: spec.acceptance ?? [],
1241
1239
  })
1242
1240
  : null;
1243
1241
  const message = verificationDetail
@@ -1414,7 +1412,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1414
1412
  }
1415
1413
  catch (repairError) {
1416
1414
  const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
1417
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
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}` } });
1418
1418
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
1419
1419
  return;
1420
1420
  }
@@ -1426,7 +1426,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1426
1426
  }
1427
1427
  if (repaired.status === "failed") {
1428
1428
  const message = repaired.error ?? "Delivery report repair failed";
1429
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
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}` } });
1430
1431
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
1431
1432
  return;
1432
1433
  }
@@ -1448,15 +1449,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1448
1449
  body: {
1449
1450
  error: detail,
1450
1451
  retryable: false,
1451
- failure: {
1452
- code: "delivery_report_invalid",
1453
- class: "contract",
1454
- disposition: "rework",
1455
- responsible_party: "conduit",
1456
- message: "Conduit could not prepare a valid delivery report from the completed agent run.",
1457
- next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
1458
- diagnostic_detail: detail,
1459
- },
1452
+ failure: deliveryReportRepairFailure(detail),
1460
1453
  idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
1461
1454
  },
1462
1455
  });
@@ -2090,11 +2083,29 @@ function retainDiagnosticWorktree(response) {
2090
2083
  // receive that decision, so fail safe by retaining the only copy of the failed code.
2091
2084
  return response.retain_worktree === true || response.status === "invalid_lease";
2092
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
+ }
2093
2104
  async function queueTerminal(client, taskId, terminal) {
2094
2105
  const active = client.attempt(taskId);
2095
2106
  let executionFacts = active.executionFacts;
2096
2107
  if (executionFacts && executionFacts.final_commit === null && active.worktreePath) {
2097
- const finalCommit = await finalCommitForWorkspace(active.worktreePath);
2108
+ const finalCommit = await headCommitOrNull(active.worktreePath);
2098
2109
  if (finalCommit) {
2099
2110
  executionFacts = { ...executionFacts, final_commit: finalCommit };
2100
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
+ }
@@ -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 execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], { timeout: 30_000 });
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.stdout.trim()}\n${dirty.stdout.trim()}`;
101
+ return `${head}\n${dirty.stdout.trim()}`;
99
102
  }
100
103
  catch {
101
104
  return null;
@@ -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 readPinnedRevision(dir)) ?? wantedRevision;
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.30",
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": {