@miraland-labs/conduit-bridge 0.16.40 → 0.16.42

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/driver.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { boundedTail } from "./ensure-test-evidence.js";
3
3
  import { resolveAgentTimeout } from "./execution-budget.js";
4
+ import { unknownAgentSignal, vendorKindForAntigravity } from "./failure-signal.js";
4
5
  import { existsSync } from "node:fs";
5
6
  import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
6
7
  import { join, resolve } from "node:path";
@@ -27,7 +28,7 @@ function deliveryLanguageRule(language) {
27
28
  return null;
28
29
  }
29
30
  export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
30
- export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path from the repository root — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "exact criterion text", "status": "met|not_met|unknown", "unverified_reason": "omit unless status is unknown", "unverified_detail": "one sentence naming what was missing; omit unless status is unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["exact criterion text supported by this evidence"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}';
31
+ export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path from the repository root — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "criterion key, for example c1", "status": "met|not_met|unknown", "unverified_reason": "omit unless status is unknown", "unverified_detail": "one sentence naming what was missing; omit unless status is unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["criterion keys this evidence supports, for example c1"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}';
31
32
  /**
32
33
  * Resolve an operator's ordered tier candidates against the live model list.
33
34
  * Picks the first safe candidate the CLI currently offers; without a live list
@@ -96,8 +97,12 @@ export function buildAssignmentPrompt(context) {
96
97
  lines.push("", `SCOPE\n${scope.map((item) => `- ${item}`).join("\n")}`);
97
98
  if (boundaries?.length)
98
99
  lines.push("", `BOUNDARIES — never violate these\n${boundaries.map((item) => `- ${item}`).join("\n")}`);
99
- if (acceptance?.length)
100
- lines.push("", `ACCEPTANCE CRITERIA the delivery is judged against these\n${acceptance.map((item) => `- ${item}`).join("\n")}`);
100
+ if (acceptance?.length) {
101
+ // Each criterion carries a key. The agent reports the key, not the prose: criteria are long
102
+ // sentences with backticks and quotes, and a retyped sentence made the delivery invalid at
103
+ // "Preparing delivery" although the work was done.
104
+ lines.push("", "ACCEPTANCE CRITERIA — the delivery is judged against these. The key in brackets identifies each one.", ...acceptanceCriteriaLines(acceptance), "", "Name each criterion by its key — \"c1\", not the sentence — in acceptance_results[].criterion and in evidence[].acceptance_criteria. You do not need to retype the criterion text; a retyped sentence that differs makes the whole delivery invalid.", "Start from this report skeleton and change only the statuses, the reasons, and the evidence you map to each key:", acceptanceReportSkeleton(acceptance));
105
+ }
101
106
  if (evidence?.length && !spec.instruction) {
102
107
  lines.push("", "REQUIRED EVIDENCE");
103
108
  for (const kind of evidence) {
@@ -299,6 +304,53 @@ export function criterionKey(value) {
299
304
  .replace(/[.\u3002]+$/, "")
300
305
  .toLowerCase();
301
306
  }
307
+ /**
308
+ * The key of one acceptance criterion: `c1` for the first approved criterion, `c2` for the second.
309
+ *
310
+ * The key is derived from the approved order at prompt time and at parse time. It is not stored:
311
+ * the approved criteria list of the execution epoch is the only source of the order, so a key
312
+ * cannot disagree with the contract it names.
313
+ */
314
+ export function criterionKeyLabel(index) {
315
+ return `c${index + 1}`;
316
+ }
317
+ /** A criterion reference that is a key: `c1`, `C1`, `[c1]`, or the same with spaces around it. */
318
+ const CRITERION_KEY_REFERENCE = /^\[?\s*c(\d{1,3})\s*\]?$/i;
319
+ /**
320
+ * Resolve one criterion reference from the agent report to the approved criterion text.
321
+ *
322
+ * The reference is the exact text (with the `criterionKey` normalization) or the key. The text is
323
+ * tried first, so a criterion whose own text is "c1" keeps its identity. The result is null when
324
+ * the reference names no approved criterion.
325
+ */
326
+ export function resolveCriterionReference(value, acceptance) {
327
+ const key = criterionKey(value);
328
+ const byText = acceptance.find((criterion) => criterionKey(criterion) === key);
329
+ if (byText !== undefined)
330
+ return byText;
331
+ const match = CRITERION_KEY_REFERENCE.exec(value.trim());
332
+ if (!match)
333
+ return null;
334
+ return acceptance[Number(match[1]) - 1] ?? null;
335
+ }
336
+ /** True when the reference has the shape of a key. Used to tell an unknown key from free prose. */
337
+ function isCriterionKeyReference(value) {
338
+ return CRITERION_KEY_REFERENCE.test(value.trim());
339
+ }
340
+ /** Render the approved criteria as `[c1] <text>` lines for a prompt. */
341
+ export function acceptanceCriteriaLines(acceptance) {
342
+ return acceptance.map((criterion, index) => `- [${criterionKeyLabel(index)}] ${criterion}`);
343
+ }
344
+ /**
345
+ * The pre-filled acceptance_results skeleton for the prompt: one entry per key, status unknown.
346
+ * The agent changes the statuses and adds the evidence instead of composing the list, because
347
+ * composing it means retyping long criterion prose, and a retyped sentence made the delivery
348
+ * invalid at "Preparing delivery" on most first deliveries.
349
+ */
350
+ export function acceptanceReportSkeleton(acceptance) {
351
+ const entries = acceptance.map((_, index) => ({ criterion: criterionKeyLabel(index), status: "unknown" }));
352
+ return JSON.stringify({ acceptance_results: entries });
353
+ }
302
354
  /** Bound one criterion for an error message. A criterion holds up to 4 000 characters. */
303
355
  function quoteCriterion(value) {
304
356
  const text = value.trim().replace(/\s+/g, " ");
@@ -335,13 +387,34 @@ export function parseAgentReport(text, acceptance) {
335
387
  const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
336
388
  throw new Error(`Agent report is invalid${at}: ${issue?.message ?? "unknown validation error"}`);
337
389
  }
338
- const reportedKeys = parsed.data.acceptance_results.map((item) => criterionKey(item.criterion));
390
+ // Map every criterion reference — in the results and in the evidence — back to the approved text
391
+ // before anything else reads it. Downstream (the packet, the reviewer, Today) sees text only; the
392
+ // key is transport. An unknown key is refused here, because a key the agent invented cannot be
393
+ // mapped and silently leaves a criterion unwitnessed.
394
+ const unknownKeys = [];
395
+ const toApprovedText = (value) => {
396
+ const text = resolveCriterionReference(value, acceptance);
397
+ if (text !== null)
398
+ return text;
399
+ if (isCriterionKeyReference(value))
400
+ unknownKeys.push(value.trim());
401
+ return value;
402
+ };
403
+ const acceptanceResults = parsed.data.acceptance_results.map((item) => ({ ...item, criterion: toApprovedText(item.criterion) }));
404
+ const evidence = parsed.data.evidence.map((item) => ({ ...item, acceptance_criteria: item.acceptance_criteria.map(toApprovedText) }));
405
+ if (unknownKeys.length) {
406
+ const valid = acceptance.length
407
+ ? acceptance.map((criterion, index) => `${criterionKeyLabel(index)} = ${quoteCriterion(criterion)}`).join("; ")
408
+ : "none — this package has no acceptance criteria";
409
+ throw new Error(`Agent report names a criterion key that does not exist (${[...new Set(unknownKeys)].join(", ")}). The valid keys are: ${valid}`);
410
+ }
411
+ const reportedKeys = acceptanceResults.map((item) => criterionKey(item.criterion));
339
412
  const approvedKeys = new Set(acceptance.map(criterionKey));
340
- const duplicated = parsed.data.acceptance_results
413
+ const duplicated = acceptanceResults
341
414
  .filter((item, index) => reportedKeys.indexOf(criterionKey(item.criterion)) !== index)
342
415
  .map((item) => item.criterion);
343
416
  const missing = acceptance.filter((criterion) => !reportedKeys.includes(criterionKey(criterion)));
344
- const unapproved = parsed.data.acceptance_results
417
+ const unapproved = acceptanceResults
345
418
  .filter((item) => !approvedKeys.has(criterionKey(item.criterion)))
346
419
  .map((item) => item.criterion);
347
420
  if (missing.length || duplicated.length || unapproved.length) {
@@ -357,10 +430,11 @@ export function parseAgentReport(text, acceptance) {
357
430
  // Missing evidence mappings cannot support a "met" claim. Preserve the evidence itself, but
358
431
  // downgrade only the unsupported result to unknown so the existing quality loop can assess the
359
432
  // completed work instead of throwing the whole implementation away.
360
- const mappedCriteria = new Set(parsed.data.evidence.flatMap((item) => item.acceptance_criteria.map(criterionKey)));
433
+ const mappedCriteria = new Set(evidence.flatMap((item) => item.acceptance_criteria.map(criterionKey)));
361
434
  return {
362
435
  ...parsed.data,
363
- acceptance_results: parsed.data.acceptance_results.map((item) => {
436
+ evidence,
437
+ acceptance_results: acceptanceResults.map((item) => {
364
438
  const { unverified_reason: reported, unverified_detail: detail, ...rest } = item;
365
439
  const status = item.status === "met" && !mappedCriteria.has(criterionKey(item.criterion))
366
440
  ? "unknown"
@@ -524,7 +598,7 @@ export const claudeCodeDriver = {
524
598
  }
525
599
  const sessionId = message?.session_id ?? null;
526
600
  if (code !== 0 || !message || message.is_error) {
527
- return { status: "failed", resultText: message?.result ?? null, sessionId, error: boundedTail(message?.result || stderr || `agent exited with code ${code}`, 20_000) };
601
+ return { status: "failed", resultText: message?.result ?? null, sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(message?.result || stderr || `agent exited with code ${code}`, 20_000) };
528
602
  }
529
603
  return { status: "completed", resultText: message.result ?? "", sessionId };
530
604
  },
@@ -625,7 +699,7 @@ export const codexDriver = {
625
699
  const parsed = parseCodexJsonl(stdout);
626
700
  const resultText = parsed.resultText ?? (stdout || null);
627
701
  if (code !== 0) {
628
- return { status: "failed", resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || stdout || `codex exited with code ${code}`, 20_000) };
702
+ return { status: "failed", resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || stdout || `codex exited with code ${code}`, 20_000) };
629
703
  }
630
704
  return { status: "completed", resultText, sessionId: parsed.sessionId };
631
705
  },
@@ -757,7 +831,7 @@ export const cursorDriver = {
757
831
  const { code, stdout, stderr } = configured;
758
832
  const parsed = parseCursorOutput(stdout);
759
833
  if (code !== 0 || parsed.isError) {
760
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `cursor agent exited with code ${code}`, 20_000) };
834
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || parsed.resultText || `cursor agent exited with code ${code}`, 20_000) };
761
835
  }
762
836
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
763
837
  },
@@ -924,7 +998,7 @@ export const openCodeDriver = {
924
998
  const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
925
999
  const parsed = parseOpenCodeOutput(stdout);
926
1000
  if (code !== 0 || parsed.isError) {
927
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `opencode exited with code ${code}`, 20_000) };
1001
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || parsed.resultText || `opencode exited with code ${code}`, 20_000) };
928
1002
  }
929
1003
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
930
1004
  },
@@ -989,7 +1063,7 @@ export const kiroDriver = {
989
1063
  args.push(input.prompt);
990
1064
  const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
991
1065
  if (code !== 0) {
992
- return { status: "failed", resultText: stdout || null, sessionId: null, error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
1066
+ return { status: "failed", resultText: stdout || null, sessionId: null, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
993
1067
  }
994
1068
  return { status: "completed", resultText: stdout, sessionId: null };
995
1069
  },
@@ -1218,6 +1292,24 @@ async function withAntigravityPermissions(workspace, allow, run) {
1218
1292
  release();
1219
1293
  }
1220
1294
  }
1295
+ /**
1296
+ * The typed signal for a refused agy run: the envelope's own status and reason become the witness,
1297
+ * and the vendor's vocabulary names the kind — so the control plane can hold a spent allowance
1298
+ * without any generic prose table knowing agy at all.
1299
+ */
1300
+ function agySignal(code, status, message, stderr, stdout) {
1301
+ const kind = vendorKindForAntigravity(status, message);
1302
+ if (kind === "unknown")
1303
+ return unknownAgentSignal({ exit_code: code, stderr, stdout });
1304
+ return {
1305
+ phase: "agent",
1306
+ witness: "vendor",
1307
+ kind,
1308
+ exit_code: code,
1309
+ ...(status ? { vendor_status: status.slice(0, 200) } : {}),
1310
+ ...(message ? { vendor_message: boundedTail(message, 2_000) } : {}),
1311
+ };
1312
+ }
1221
1313
  export const antigravityDriver = {
1222
1314
  name: "antigravity",
1223
1315
  async listModels(executable = "agy", workspace = process.cwd()) {
@@ -1260,12 +1352,20 @@ export const antigravityDriver = {
1260
1352
  // it is the only text that names the cause (a spent allowance and when it refills).
1261
1353
  const refused = code !== 0 || (parsed.status !== null && parsed.status.toUpperCase() !== "SUCCESS");
1262
1354
  if (refused) {
1263
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000) };
1355
+ return {
1356
+ status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId,
1357
+ signal: agySignal(code, parsed.status, parsed.error, stderr, stdout),
1358
+ error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000),
1359
+ };
1264
1360
  }
1265
1361
  // An empty response with a denial on stderr is a refused run: report it instead of an empty reply.
1266
1362
  const denial = parsed.resultText?.trim() ? null : antigravityDenialNotice(stderr);
1267
1363
  if (denial) {
1268
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(`agy returned no response: ${denial}`, 20_000) };
1364
+ return {
1365
+ status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId,
1366
+ signal: { phase: "agent", witness: "vendor", kind: "denied_tool", vendor_status: parsed.status ?? "ERROR", vendor_message: boundedTail(denial, 2_000) },
1367
+ error: boundedTail(`agy returned no response: ${denial}`, 20_000),
1368
+ };
1269
1369
  }
1270
1370
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
1271
1371
  },
@@ -1318,6 +1418,7 @@ export const piDriver = {
1318
1418
  status: "failed",
1319
1419
  resultText: parsed.resultText,
1320
1420
  sessionId: parsed.sessionId,
1421
+ signal: unknownAgentSignal({ exit_code: code, stderr, stdout }),
1321
1422
  error: boundedTail(stderr || parsed.resultText || `pi exited with code ${code}`, 20_000),
1322
1423
  };
1323
1424
  }
@@ -1450,7 +1551,7 @@ export const grokDriver = {
1450
1551
  const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1451
1552
  const parsed = parseGrokOutput(stdout);
1452
1553
  if (code !== 0 || parsed.isError) {
1453
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `grok exited with code ${code}`, 20_000) };
1554
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || parsed.resultText || `grok exited with code ${code}`, 20_000) };
1454
1555
  }
1455
1556
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
1456
1557
  },
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
+ import { resolveCriterionReference } from "./driver.js";
8
9
  import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  const TEST_EVIDENCE_DETAILS_MIN = 32;
@@ -287,8 +288,10 @@ export function criteriaForTestEvidence(report, command, acceptance = []) {
287
288
  const mapped = new Set();
288
289
  for (const item of sources) {
289
290
  for (const criterion of item.acceptance_criteria ?? []) {
291
+ // The agent may name a criterion by its key (c1). The witness carries the approved text, so
292
+ // that a key never reaches the packet and the criterion is not left unwitnessed.
290
293
  if (criterion)
291
- mapped.add(criterion);
294
+ mapped.add(resolveCriterionReference(criterion, acceptance) ?? criterion);
292
295
  }
293
296
  }
294
297
  // A criterion that names the command verbatim is proved by that command's witness, whether or
package/dist/execution.js CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { resolveAgentTimeout } from "./execution-budget.js";
5
5
  import { ConduitRequestError } from "./client.js";
6
6
  import { redactSecrets, saveDriverOutcome, saveDriverQuota } from "./config.js";
7
- import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
7
+ import { DRIVERS, acceptanceCriteriaLines, acceptanceReportSkeleton, 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";
10
10
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
13
13
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
14
14
  import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, workspaceIsClean } from "./execution-facts.js";
15
15
  import { forgeTransportFailure } from "./transport-fault.js";
16
+ import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
16
17
  import { headCommitOrNull } from "./git.js";
17
18
  import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
18
19
  import { bridgeVersion } from "./version.js";
@@ -96,14 +97,18 @@ export function providerQuotaRefusal(message) {
96
97
  */
97
98
  export function providerQuotaFailure(detail) {
98
99
  const resets = /resets? in ([^.\n]{1,40})/i.exec(detail);
100
+ // The absolute time wins when the vendor's words parse into one — an owner reads a clock, not
101
+ // arithmetic. The vendor's own phrasing is the fallback, and no reset stated stays unsaid.
102
+ const absolute = parseQuotaResetAt(detail);
103
+ const refills = absolute
104
+ ? `It refills at ${formatResetMinute(absolute)}.`
105
+ : resets ? `It refills in ${resets[1].trim()}.` : "";
99
106
  return {
100
107
  code: "driver_quota_exhausted",
101
108
  class: "environment",
102
109
  disposition: "hold",
103
110
  responsible_party: "computer_operator",
104
- message: resets
105
- ? `The agent lane on this computer has spent its subscription allowance. It refills in ${resets[1].trim()}.`
106
- : "The agent lane on this computer has spent its subscription allowance.",
111
+ message: `The agent lane on this computer has spent its subscription allowance.${refills ? ` ${refills}` : ""}`,
107
112
  next_action: "Wait for the allowance to refill, bring another agent lane online to start sooner, or point this lane's model settings at a model that still has allowance.",
108
113
  diagnostic_detail: detail,
109
114
  };
@@ -159,66 +164,6 @@ function timeoutFailureBody(resolution, attemptId) {
159
164
  }
160
165
  class AgentTurnTimeoutError extends Error {
161
166
  }
162
- /**
163
- * When the window refills, if the vendor said so.
164
- *
165
- * Vendors phrase this as an absolute clock time ("resets at 3pm"), a duration ("try again in 2h
166
- * 14m"), or a Unix epoch. Returning null when none matches is fine — `laneQuota` treats a record
167
- * without a reset as exhausted until something proves otherwise, and the next successful run clears
168
- * it. Guessing a reset time would be worse: an invented one un-darks the lane early and the owner
169
- * watches the same refusal twice.
170
- */
171
- export function parseQuotaResetAt(message, now = Date.now()) {
172
- const epoch = /\bresets?[ _-]?(?:at|in)?\b\D{0,12}(\d{10})\b/i.exec(message);
173
- if (epoch)
174
- return plausibleReset(Number(epoch[1]) * 1000, now);
175
- // Units must end where they claim to. Unanchored, `m` swallowed the "m" of "500ms" and of
176
- // "2 months" and read both as minutes — turning a 500-millisecond backoff into an eight-hour
177
- // blackout of the only local lane.
178
- const duration = /\b(?:try again|retry|resets?|available again)\b[^.\n]{0,24}?\bin\b\s*(?:(\d+)\s*(?:h|hrs?|hours?)\b(?!\w))?\s*(?:(\d+)\s*(?:m|mins?|minutes?)\b(?!\w))?\s*(?:(\d+)\s*(?:s|secs?|seconds?)\b(?!\w))?\s*(?:(\d+)\s*(?:ms|msecs?|milliseconds?)\b(?!\w))?/i.exec(message);
179
- if (duration && (duration[1] || duration[2] || duration[3] || duration[4])) {
180
- const ms = (Number(duration[1] ?? 0) * 3_600 + Number(duration[2] ?? 0) * 60 + Number(duration[3] ?? 0)) * 1_000
181
- + Number(duration[4] ?? 0);
182
- if (ms > 0)
183
- return plausibleReset(now + ms, now);
184
- }
185
- const iso = /\bresets?\b[^.\n]{0,24}?(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i.exec(message);
186
- if (iso)
187
- return plausibleReset(Date.parse(iso[1]), now);
188
- return null;
189
- }
190
- /**
191
- * A reset time worth believing, or none at all.
192
- *
193
- * Two failure modes this closes. A number lifted from prose is not always a clock — a request id
194
- * that happens to be ten digits reads as a date in 2286 and would dark the lane for centuries. And
195
- * `new Date(x).toISOString()` *throws* on a non-finite or out-of-range value, which here would
196
- * escape between the driver returning and `queueTerminal`, stranding the attempt in `agent_running`.
197
- * Anything outside a plausible billing window is treated as "no reset stated", which is the honest
198
- * answer and already a supported state.
199
- */
200
- function plausibleReset(ms, now) {
201
- if (!Number.isFinite(ms))
202
- return null;
203
- if (ms > now + MAX_PLAUSIBLE_RESET_MS)
204
- return null;
205
- // A reset that has just passed is kept, not discarded. Rejecting it returned null, which does not
206
- // mean "already refilled" — it means "no reset stated", the one case trusted for a whole week. A
207
- // second of clock skew or a slow stderr flush would then dark the lane for seven days instead of
208
- // clearing it at once. Keeping the stale timestamp lets `laneQuota` retire it on the next read.
209
- if (ms < now - MAX_STALE_RESET_MS)
210
- return null;
211
- try {
212
- return new Date(ms).toISOString();
213
- }
214
- catch {
215
- return null;
216
- }
217
- }
218
- /** No vendor window runs longer than a month; past that the parse was a coincidence, not a clock. */
219
- const MAX_PLAUSIBLE_RESET_MS = 31 * 24 * 3_600_000;
220
- /** How far back a reset may sit and still be a real one the run simply outlived. */
221
- const MAX_STALE_RESET_MS = 24 * 3_600_000;
222
167
  /** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
223
168
  const FINALIZE_ENVIRONMENT_PATTERN = /base[_ ]not[_ ]ancestor|required base commit is not available|source[_ ]workspace[_ ]dirty|uncommitted changes|dirty workspace|workspace[_ ]head[_ ]changed|workspace[_ ]repository|workspace[_ ]unavailable|driver[_ ]not[_ ]authenticated|not logged in|no login|not authenticated|login required|no[_ ]online[_ ]driver|bridge[_ ]preflight|stale bridge|bridge version/i;
224
169
  /** Delivery-report / grant / evidence contract defects (non-retryable rework). */
@@ -1290,10 +1235,19 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1290
1235
  // Keep the tree until the replay-safe terminal response explicitly says whether diagnosis was
1291
1236
  // queued. A network fault here must not delete the evidence before Bridge can replay terminal.
1292
1237
  retainAttemptWorktree = true;
1238
+ // The driver's typed cause rides beside the prose, never instead of it. A recovered
1239
+ // verification log describes the same run: the signal becomes Bridge's verification witness
1240
+ // and the prose carries the evidence, so the verify class stays typed without new tables.
1241
+ const signal = result.signal
1242
+ ? verificationDetail
1243
+ ? { ...result.signal, phase: "verification", witness: "bridge", stderr_tail: undefined, stdout_tail: undefined }
1244
+ : result.signal
1245
+ : undefined;
1293
1246
  const response = await queueTerminal(client, taskId, { action: "fail", body: {
1294
1247
  error: message,
1295
1248
  retryable: retryableAgentFailure(agentMessage),
1296
1249
  ...(providerQuotaRefusal(agentMessage) ? { failure: providerQuotaFailure(agentMessage) } : {}),
1250
+ ...(signal ? { signal } : {}),
1297
1251
  idempotency_key: `bridge:fail:${active.attemptId}`,
1298
1252
  } });
1299
1253
  retainAttemptWorktree = response.retain_worktree === true;
@@ -1721,8 +1675,8 @@ function buildLandContinuationPrompt(input) {
1721
1675
  "APPROVED CHANGE SCOPE",
1722
1676
  ...(scope.length ? scope.map((path) => `- ${path}`) : ["- (none — stay within the plan)"]),
1723
1677
  "",
1724
- "APPROVED ACCEPTANCE CRITERIA",
1725
- ...(input.acceptance.length ? input.acceptance.map((criterion) => `- ${criterion}`) : ["- None"]),
1678
+ "APPROVED ACCEPTANCE CRITERIA — name each one by its key, not by its text",
1679
+ ...(input.acceptance.length ? acceptanceCriteriaLines(input.acceptance) : ["- None"]),
1726
1680
  "",
1727
1681
  "REQUIRED DELIVERY SHAPE",
1728
1682
  agentReportTemplate,
@@ -1840,8 +1794,11 @@ function buildDeliveryRepairPrompt(parseError, previousReply, acceptance) {
1840
1794
  "REQUIRED DELIVERY SHAPE",
1841
1795
  agentReportTemplate,
1842
1796
  "",
1843
- "APPROVED ACCEPTANCE CRITERIA",
1844
- ...(acceptance.length ? acceptance.map((criterion) => `- ${criterion}`) : ["- None"]),
1797
+ "APPROVED ACCEPTANCE CRITERIA — name each one by its key, not by its text",
1798
+ ...(acceptance.length ? acceptanceCriteriaLines(acceptance) : ["- None"]),
1799
+ ...(acceptance.length
1800
+ ? ["", "Start from this report skeleton and change only the statuses, the reasons, and the evidence:", acceptanceReportSkeleton(acceptance)]
1801
+ : []),
1845
1802
  "",
1846
1803
  "PARSE ERROR",
1847
1804
  redactSecrets(parseError),
@@ -0,0 +1,180 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * A typed failure signal from Bridge: what ended the run, from the witness that saw it.
4
+ *
5
+ * Bridge reports failures as prose (`error`), and both sides classify the prose with regex tables
6
+ * kept in step by a test. Anything the tables do not name became `agent_execution_failed` /
7
+ * `transient` / `retry` — the one classification that spends attempts silently. The signal rides
8
+ * beside the prose: drivers that hold structured knowledge (a vendor envelope status, an exit
9
+ * code, the denied tool) emit it, and classification reads the signal first, the regex second.
10
+ *
11
+ * This file is mirrored byte-identical as packages/conduit-bridge/src/failure-signal.ts and
12
+ * src/conductor/failure-signal.ts. Bridge cannot import from the control plane, and the other
13
+ * way round, so test/failure-signal.test.ts pins the two files together instead of trusting
14
+ * them to stay in step.
15
+ */
16
+ export const failureSignalSchema = z.object({
17
+ phase: z.enum(["claim", "checkout", "bootstrap", "agent", "verification", "delivery_report", "land"]),
18
+ /** Who produced the evidence for this signal: Bridge itself, the agent CLI, or the vendor. */
19
+ witness: z.enum(["bridge", "agent", "vendor"]),
20
+ kind: z.enum(["exit_code", "timeout", "denied_tool", "quota", "usage_error", "no_output", "contract", "verification_failed", "unknown"]),
21
+ /** The exact command when phase is verification or bootstrap. */
22
+ command: z.string().max(2_000).optional(),
23
+ exit_code: z.number().int().nullable().optional(),
24
+ /** The vendor envelope's own status, e.g. agy's "ERROR". */
25
+ vendor_status: z.string().max(200).optional(),
26
+ /** The vendor envelope's own reason, bounded. */
27
+ vendor_message: z.string().max(2_000).optional(),
28
+ stderr_tail: z.string().max(2_000).optional(),
29
+ stdout_tail: z.string().max(2_000).optional(),
30
+ /** ISO time when kind is quota and the vendor said. */
31
+ resets_at: z.string().max(64).nullable().optional(),
32
+ });
33
+ /** A reset time as a person reads it: "2026-09-10 09:36 UTC". Every reset is stored ISO in UTC. */
34
+ function resetClock(resetsAt) {
35
+ return `${resetsAt.slice(0, 16).replace("T", " ")} UTC`;
36
+ }
37
+ /** Signal tails are bounded: one runaway log cannot fill the wire or the card. */
38
+ function bounded2k(text) {
39
+ return text.length > 2_000 ? `${text.slice(0, 1_999)}…` : text;
40
+ }
41
+ /** The signal a driver that cannot say more emits: the run ended, here is its last output. */
42
+ export function unknownAgentSignal(input) {
43
+ return {
44
+ phase: "agent",
45
+ witness: "agent",
46
+ kind: "unknown",
47
+ exit_code: input.exit_code ?? null,
48
+ ...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
49
+ ...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
50
+ };
51
+ }
52
+ /** The vendor's own reason, kept inside the 2,000-char message bound. */
53
+ function quotedVendorText(signal) {
54
+ const text = signal.vendor_message ?? signal.stderr_tail ?? "";
55
+ return text.length > 400 ? `${text.slice(0, 399)}…` : text;
56
+ }
57
+ /**
58
+ * Classify one typed signal. Null when the signal carries no typed verdict (`kind: "unknown"`):
59
+ * the caller then lets its regex tables name the cause as an upgrade, and an unknown that survives
60
+ * them is held by `unknownSignalHold` — never retried blind.
61
+ *
62
+ * Rules, in order:
63
+ * 1. quota → the lane's allowance is spent: Hold, the computer operator's wait, reset quoted.
64
+ * 2. denied_tool / usage_error → the driver and vendor cannot run this assignment: Hold.
65
+ * 3. verification run by Bridge → the verify class: diagnose the retained tree and brief a repair.
66
+ * 4. contract, or a delivery_report phase → the delivery contract codes: rework.
67
+ * 5. timeout → the turn reached its execution limit: stop.
68
+ */
69
+ export function classifyFailureSignal(signal) {
70
+ if (signal.kind === "quota") {
71
+ const refill = signal.resets_at
72
+ ? ` It refills at ${resetClock(signal.resets_at)}.`
73
+ : signal.vendor_message
74
+ ? ` The vendor said: ${quotedVendorText(signal)}`
75
+ : "";
76
+ return {
77
+ code: "driver_quota_exhausted",
78
+ class: "environment",
79
+ disposition: "hold",
80
+ responsible_party: "computer_operator",
81
+ message: `The agent lane on this computer has spent its subscription allowance.${refill}`,
82
+ next_action: "Wait for the allowance to refill, bring another agent lane online to start sooner, or point this lane's model settings at a model that still has allowance.",
83
+ diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
84
+ };
85
+ }
86
+ if (signal.kind === "denied_tool" || signal.kind === "usage_error") {
87
+ return {
88
+ code: "driver_incompatible",
89
+ class: "environment",
90
+ disposition: "hold",
91
+ responsible_party: "computer_operator",
92
+ message: signal.kind === "denied_tool"
93
+ ? "The agent CLI refused a tool this assignment requires, so it cannot run here as configured."
94
+ : "The agent CLI rejected how this assignment was handed to it, so every retry fails the same way.",
95
+ next_action: "The computer operator or Bridge must fix the lane's tool mapping or driver flags. The product owner does not author technical constraints.",
96
+ diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
97
+ };
98
+ }
99
+ if (signal.phase === "verification" && signal.witness === "bridge") {
100
+ return {
101
+ code: "agent_execution_failed",
102
+ class: "transient",
103
+ disposition: "retry",
104
+ responsible_party: "conduit",
105
+ message: "The agent run failed its own verification before delivery.",
106
+ next_action: "Conduit will diagnose the retained failed run and brief the next authoring attempt.",
107
+ diagnostic_detail: [signal.command ? `$ ${signal.command}` : null, signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n") || undefined,
108
+ };
109
+ }
110
+ if (signal.phase === "delivery_report" || signal.kind === "contract") {
111
+ return signal.phase === "delivery_report"
112
+ ? {
113
+ code: "delivery_report_invalid",
114
+ class: "contract",
115
+ disposition: "rework",
116
+ responsible_party: "conduit",
117
+ message: "The delivery report did not satisfy the delivery contract.",
118
+ next_action: "Bridge's report-only repair turns ran; review the report defect and rework it.",
119
+ diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
120
+ }
121
+ : {
122
+ code: "execution_contract_failed",
123
+ class: "contract",
124
+ disposition: "rework",
125
+ responsible_party: "conduit",
126
+ message: "The agent could not satisfy the approved execution contract.",
127
+ next_action: "Review the last run and rework the same contract; change the plan only if its authority or outcome is wrong.",
128
+ diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
129
+ };
130
+ }
131
+ if (signal.kind === "timeout") {
132
+ return {
133
+ code: "agent_turn_timeout",
134
+ class: "platform",
135
+ disposition: "stop",
136
+ responsible_party: "conduit",
137
+ message: "The agent turn reached its configured execution limit.",
138
+ next_action: "Conduit must compare the package budget with the computer ceiling and resume with a larger bounded turn or checkpoint. The product owner does not need to author implementation constraints.",
139
+ diagnostic_detail: signal.stderr_tail,
140
+ };
141
+ }
142
+ return null;
143
+ }
144
+ /**
145
+ * The one verdict for an unknown that survived every table: Hold, on Conduit.
146
+ *
147
+ * An unknown signal used to fall through to `transient` and burn attempts silently. The owner now
148
+ * sees the run's own last words — the bounded stderr tail — and Conduit owes the diagnosis, because
149
+ * nobody can yet name the cause.
150
+ */
151
+ export function unknownSignalHold(signal, proseDetail) {
152
+ const tail = signal.stderr_tail ?? signal.stdout_tail ?? "";
153
+ return {
154
+ code: "agent_run_unexplained",
155
+ class: "environment",
156
+ disposition: "hold",
157
+ responsible_party: "conduit",
158
+ message: tail
159
+ ? `The agent run failed and neither Bridge nor Conduit can yet name the cause. The run's last output: ${quotedVendorText(signal)}`
160
+ : "The agent run failed and neither Bridge nor Conduit can yet name the cause.",
161
+ next_action: "Conduit will diagnose the retained run before any retry. The product owner does not need to author technical constraints.",
162
+ diagnostic_detail: proseDetail || tail || undefined,
163
+ };
164
+ }
165
+ /**
166
+ * agy's JSON envelope status/error mapped to a typed kind, from the vendor's own vocabulary.
167
+ *
168
+ * This is vendor knowledge, not a classification table: it reads the words agy itself prints in
169
+ * its envelope, so the signal can carry `quota` (etc.) and the generic tables stay empty of agy.
170
+ */
171
+ export function vendorKindForAntigravity(vendorStatus, vendorMessage) {
172
+ const text = `${vendorStatus ?? ""}\n${vendorMessage ?? ""}`;
173
+ if (/individual quota reached|quota reached|upgrade your subscription/i.test(text))
174
+ return "quota";
175
+ if (/no output produced|headless mode cannot prompt for|was auto-denied|permission/i.test(text))
176
+ return "denied_tool";
177
+ if (/took "[^"]*" as its prompt|Usage of agy|unknown (?:option|argument)|unrecognized (?:option|argument)/i.test(text))
178
+ return "usage_error";
179
+ return "unknown";
180
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The refill time a vendor states in a quota refusal, as an absolute clock.
3
+ *
4
+ * This file is mirrored byte-identical as packages/conduit-bridge/src/quota-reset.ts and
5
+ * src/conductor/quota-reset.ts. Bridge cannot import from the control plane, and the other way
6
+ * round, so test/failure-classification.test.ts pins the two files together instead of trusting
7
+ * them to stay in step.
8
+ *
9
+ * Vendors phrase the refill as an absolute clock time (RFC 1123), a duration ("try again in 2h
10
+ * 14m", or agy's Go form "Resets in 165h48m43s"), or a Unix epoch. Returning null when none
11
+ * matches is fine — `laneQuota` treats a record without a reset as exhausted until something
12
+ * proves otherwise, and the next successful run clears it. Guessing a reset time would be worse:
13
+ * an invented one un-darks the lane early, and the owner watches the same refusal twice.
14
+ */
15
+ export function parseQuotaResetAt(message, now = Date.now()) {
16
+ const epoch = /\bresets?[ _-]?(?:at|in)?\b\D{0,12}(\d{10})\b/i.exec(message);
17
+ if (epoch)
18
+ return plausibleReset(Number(epoch[1]) * 1000, now);
19
+ // Units must end where they claim to. Unanchored, `m` swallowed the "m" of "500ms" and of
20
+ // "2 months" and read both as minutes — turning a 500-millisecond backoff into an eight-hour
21
+ // blackout of the only local lane.
22
+ const duration = /\b(?:try again|retry|resets?|available again)\b[^.\n]{0,24}?\bin\b\s*(?:(\d+)\s*(?:h|hrs?|hours?)\b(?!\w))?\s*(?:(\d+)\s*(?:m|mins?|minutes?)\b(?!\w))?\s*(?:(\d+)\s*(?:s|secs?|seconds?)\b(?!\w))?\s*(?:(\d+)\s*(?:ms|msecs?|milliseconds?)\b(?!\w))?/i.exec(message);
23
+ if (duration && (duration[1] || duration[2] || duration[3] || duration[4])) {
24
+ const ms = (Number(duration[1] ?? 0) * 3_600 + Number(duration[2] ?? 0) * 60 + Number(duration[3] ?? 0)) * 1_000
25
+ + Number(duration[4] ?? 0);
26
+ if (ms > 0)
27
+ return plausibleReset(now + ms, now);
28
+ }
29
+ // Go prints the same duration with the units glued to the numbers ("Resets in 165h48m43s",
30
+ // under an hour "48m43s", "45m0s"), where no unit ends at a boundary and the spaced pattern
31
+ // above cannot see it. Match the token whole, then let the trailing `\b(?!\w)` reject a glued
32
+ // "ms": "1h30ms" is one hour plus 30 milliseconds, and reading that 30 as minutes is the
33
+ // eight-hour blackout the spaced rule guards against.
34
+ const compact = /\b(?:try again|retry|resets?|available again)\b[^.\n]{0,24}?\bin\s+((?:(\d{1,5})h)?(?:(\d{1,2})m)?(?:(\d{1,2})s)?)\b(?!\w)/i.exec(message);
35
+ if (compact && (compact[2] || compact[3] || compact[4])) {
36
+ const ms = Number(compact[2] ?? 0) * 3_600_000 + Number(compact[3] ?? 0) * 60_000 + Number(compact[4] ?? 0) * 1_000;
37
+ if (ms > 0)
38
+ return plausibleReset(now + ms, now);
39
+ }
40
+ const iso = /\bresets?\b[^.\n]{0,24}?(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i.exec(message);
41
+ if (iso)
42
+ return plausibleReset(Date.parse(iso[1]), now);
43
+ const rfc1123 = /\bresets?\b[^.\n]{0,24}?((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{1,2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT)\b/i.exec(message);
44
+ if (rfc1123)
45
+ return plausibleReset(Date.parse(rfc1123[1]), now);
46
+ return null;
47
+ }
48
+ /**
49
+ * A reset time worth believing, or none at all.
50
+ *
51
+ * Two failure modes this closes. A number lifted from prose is not always a clock — a request id
52
+ * that happens to be ten digits reads as a date in 2286 and would dark the lane for centuries. And
53
+ * `new Date(x).toISOString()` *throws* on a non-finite or out-of-range value, which here would
54
+ * escape between the driver returning and the failure being queued, stranding the attempt in a
55
+ * running state. Anything outside a plausible billing window is treated as "no reset stated",
56
+ * which is the honest answer and already a supported state.
57
+ */
58
+ function plausibleReset(ms, now) {
59
+ if (!Number.isFinite(ms))
60
+ return null;
61
+ if (ms > now + MAX_PLAUSIBLE_RESET_MS)
62
+ return null;
63
+ // A reset that has just passed is kept, not discarded. Rejecting it returned null, which does not
64
+ // mean "already refilled" — it means "no reset stated", the one case trusted for a whole week. A
65
+ // second of clock skew or a slow stderr flush would then dark the lane for seven days instead of
66
+ // clearing it at once. Keeping the stale timestamp lets `laneQuota` retire it on the next read.
67
+ if (ms < now - MAX_STALE_RESET_MS)
68
+ return null;
69
+ try {
70
+ return new Date(ms).toISOString();
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** No vendor window runs longer than a month; past that the parse was a coincidence, not a clock. */
77
+ const MAX_PLAUSIBLE_RESET_MS = 31 * 24 * 3_600_000;
78
+ /** How far back a reset may sit and still be a real one the run simply outlived. */
79
+ const MAX_STALE_RESET_MS = 24 * 3_600_000;
80
+ /**
81
+ * The absolute reset as a person reads it: "2026-09-10 09:36 UTC".
82
+ *
83
+ * Every reset is stored as ISO in UTC, so the same cut of the string is the same moment on both
84
+ * sides of the wire, and no locale can make the two screens disagree.
85
+ */
86
+ export function formatResetMinute(resetsAt) {
87
+ return `${resetsAt.slice(0, 16).replace("T", " ")} UTC`;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.40",
3
+ "version": "0.16.42",
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": {