@miraland-labs/conduit-bridge 0.14.5 → 0.14.7

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
@@ -15,7 +15,7 @@ function deliveryLanguageRule(language) {
15
15
  return null;
16
16
  }
17
17
  export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
18
- export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "exact criterion text", "status": "met|not_met|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"}';
18
+ export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path — 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"}';
19
19
  /**
20
20
  * Resolve an operator's ordered tier candidates against the live model list.
21
21
  * Picks the first safe candidate the CLI currently offers; without a live list
@@ -136,26 +136,54 @@ export function buildAssignmentPrompt(context) {
136
136
  // A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
137
137
  // mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
138
138
  // delivery lost. Say it here rather than let the agent discover it by failing.
139
- "- EVERY criterion you mark \"met\" must appear in the acceptance_criteria list of at least one evidence entry. A met criterion with no evidence mapped to it fails the whole delivery — mark it unknown instead, or add the evidence that supports it.", "", "When the work is finished, end your reply with exactly one fenced ```json block:", agentReportTemplate);
139
+ "- EVERY criterion you mark \"met\" must appear in the acceptance_criteria list of at least one evidence entry. A met criterion with no evidence mapped to it fails the whole delivery — mark it unknown instead, or add the evidence that supports it.",
140
+ // The owner has to choose between returning the delivery and accepting it. Without a reason
141
+ // they cannot tell a check another attempt could make from one no attempt can, so a return
142
+ // spends a budget and comes back identical (initiative efa56ccd, twice).
143
+ "- Every criterion you mark \"unknown\" must carry unverified_reason: not_attempted when you simply did not check it and another run could; needs_authority when this package has no grant or capability for it; needs_environment when it needs a running or deployed system you cannot start here; needs_third_party when it needs an account or platform outside this workspace; needs_human_judgment when only a person can assess it. Say which one — it decides whether the owner returns the work or accepts it.",
144
+ // A code can only say the class. The owner reads a sentence, and the sentence needs the
145
+ // particular: which app, which authority, which account. The run already knows it.
146
+ "- With it, write unverified_detail: one sentence naming exactly what was missing, in the owner's terms. \"Checking this needs the app deployed and running, and this task cannot deploy\" — not \"environment unavailable\".", "", "When the work is finished, end your reply with exactly one fenced ```json block:", agentReportTemplate);
140
147
  return lines.join("\n");
141
148
  }
142
149
  /**
143
- * Extract report JSON text from an agent reply. Prefers the last closed ```json fence,
144
- * then an unclosed ```json fence (truncated replies), then a balanced top-level object.
150
+ * Extract report JSON text from an agent reply, preferring the last closed ```json fence, then
151
+ * earlier fences, then an unclosed one (truncated replies), then a balanced top-level object.
152
+ *
153
+ * A candidate only wins if it parses. The closed-fence pattern is lazy, so it ends at the first
154
+ * ``` it meets — including one inside the report's own strings. That is not a rare accident: a
155
+ * report about markdown rendering describes fences, so its summary contains them, and the extractor
156
+ * then preferred a truncated fragment over the perfectly recoverable whole. Every retry wrote the
157
+ * same summary and failed the same way, which is what "malformed report JSON, again" was.
145
158
  */
146
159
  export function extractAgentReportJsonText(text) {
147
- const closed = [...text.matchAll(/```json\s*([\s\S]*?)```/gi)];
148
- const fromFence = closed.at(-1)?.[1]?.trim();
149
- if (fromFence)
150
- return fromFence;
151
- const unclosed = /```json\s*([\s\S]*)$/i.exec(text);
152
- const fromUnclosed = unclosed?.[1]?.trim();
153
- if (fromUnclosed)
154
- return fromUnclosed;
160
+ const candidates = [];
161
+ // Anchor on each fence *opener*, last first — the report is the closing act of a reply that may
162
+ // quote examples before it. Taking everything after an opener rather than up to the next ```
163
+ // leaves the closing fence for the balanced-object scan to step over, which is what lets a report
164
+ // containing fences, and a reply truncated mid-fence, both recover.
165
+ for (const opener of [...text.matchAll(/```json[ \t]*\r?\n?/gi)].reverse()) {
166
+ const start = (opener.index ?? 0) + opener[0].length;
167
+ const value = text.slice(start).trim();
168
+ if (value)
169
+ candidates.push(value);
170
+ }
155
171
  const start = text.indexOf("{");
156
- if (start < 0)
172
+ if (start >= 0)
173
+ candidates.push(text.slice(start).trim());
174
+ if (candidates.length === 0)
157
175
  throw new Error("Agent did not emit the required structured report");
158
- return text.slice(start).trim();
176
+ for (const candidate of candidates) {
177
+ try {
178
+ parseJsonObjectCandidate(candidate);
179
+ return candidate;
180
+ }
181
+ catch {
182
+ // Truncated at a fence inside a string, or prose that only looked like an object.
183
+ }
184
+ }
185
+ // Nothing parsed: hand back the preferred candidate so the caller reports on the real report.
186
+ return candidates[0];
159
187
  }
160
188
  /** Parse JSON, or the first balanced `{...}` object when the candidate is truncated prose. */
161
189
  export function parseJsonObjectCandidate(candidate) {
@@ -192,6 +220,54 @@ export function parseJsonObjectCandidate(candidate) {
192
220
  throw new Error("Agent emitted malformed report JSON");
193
221
  }
194
222
  }
223
+ /**
224
+ * Why this run could not decide an acceptance criterion.
225
+ *
226
+ * `unknown` alone tells the owner a check did not happen, not whether asking again could change
227
+ * that. Only `not_attempted` is recoverable by a rework; the rest describe something this work
228
+ * package can never reach, so returning the delivery spends a review budget and comes back the
229
+ * same. The runner is the only place this is knowable: it finds out by hitting the wall.
230
+ *
231
+ * Keep this list equal to `UNVERIFIED_REASONS` in src/conductor/delivery.ts. The two packages
232
+ * cannot share code, and "unverified reason agreement" holds them equal.
233
+ */
234
+ export const UNVERIFIED_REASONS = [
235
+ "not_attempted",
236
+ "needs_authority",
237
+ "needs_environment",
238
+ "needs_third_party",
239
+ "needs_human_judgment"
240
+ ];
241
+ /**
242
+ * The identity of an acceptance criterion.
243
+ *
244
+ * The contract holds criteria as prose. The Planner model writes that prose and the agent model
245
+ * reports it back, and a model rewrites typographic punctuation while it retypes. Initiative
246
+ * efa56ccd wrote "non-main" with U+2011 NON-BREAKING HYPHEN, the agent reported an ASCII hyphen,
247
+ * and Conduit discarded a completed delivery. NFKC alone does not prevent this: it folds U+2011 to
248
+ * U+2010, which is also not ASCII. Thus this function folds the dash and quote families itself.
249
+ *
250
+ * Keep this function equal to `criterionKey` in src/conductor/delivery.ts. The two packages cannot
251
+ * share code, so the test "acceptance criterion identity agreement" holds them equal.
252
+ * Write the classes as escapes. These characters are invisible or look the same in an editor.
253
+ */
254
+ export function criterionKey(value) {
255
+ return value
256
+ .normalize("NFKC")
257
+ .replace(/[\u00AD\u200B-\u200D\uFEFF]/g, "")
258
+ .replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/g, "-")
259
+ .replace(/[\u2018\u2019\u201A\u201B\u2032]/g, "'")
260
+ .replace(/[\u201C\u201D\u201E\u201F\u2033]/g, '"')
261
+ .replace(/\s+/g, " ")
262
+ .trim()
263
+ .replace(/[.\u3002]+$/, "")
264
+ .toLowerCase();
265
+ }
266
+ /** Bound one criterion for an error message. A criterion holds up to 4 000 characters. */
267
+ function quoteCriterion(value) {
268
+ const text = value.trim().replace(/\s+/g, " ");
269
+ return `"${text.length > 120 ? `${text.slice(0, 117)}...` : text}"`;
270
+ }
195
271
  /** Parse the agent's final fenced JSON block into a bounded report. */
196
272
  export function parseAgentReport(text, acceptance) {
197
273
  let raw;
@@ -209,7 +285,7 @@ export function parseAgentReport(text, acceptance) {
209
285
  outcome: z.string().trim().min(1).max(20_000),
210
286
  changes: stringList,
211
287
  verification: stringList,
212
- acceptance_results: z.array(z.object({ criterion: z.string().trim().min(1).max(4_000), status: z.enum(["met", "not_met", "unknown"]) })).max(100),
288
+ acceptance_results: z.array(z.object({ criterion: z.string().trim().min(1).max(4_000), status: z.enum(["met", "not_met", "unknown"]), unverified_reason: z.enum(UNVERIFIED_REASONS).optional(), unverified_detail: z.string().trim().min(1).max(500).optional() })).max(100),
213
289
  evidence: z.array(z.object({
214
290
  kind: z.enum(evidenceKinds), name: z.string().trim().min(1).max(500), uri: z.string().url().max(4_000).optional(),
215
291
  digest: z.string().trim().min(1).max(500).optional(), details: stringList, acceptance_criteria: stringList,
@@ -223,11 +299,48 @@ export function parseAgentReport(text, acceptance) {
223
299
  const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
224
300
  throw new Error(`Agent report is invalid${at}: ${issue?.message ?? "unknown validation error"}`);
225
301
  }
226
- const reported = parsed.data.acceptance_results.map((item) => item.criterion);
227
- if (new Set(reported).size !== reported.length || acceptance.some((criterion) => !reported.includes(criterion))) {
228
- throw new Error("Agent report must include every acceptance criterion exactly once");
302
+ const reportedKeys = parsed.data.acceptance_results.map((item) => criterionKey(item.criterion));
303
+ const duplicated = parsed.data.acceptance_results
304
+ .filter((item, index) => reportedKeys.indexOf(criterionKey(item.criterion)) !== index)
305
+ .map((item) => item.criterion);
306
+ const missing = acceptance.filter((criterion) => !reportedKeys.includes(criterionKey(criterion)));
307
+ if (missing.length || duplicated.length) {
308
+ // Name the criterion. This text becomes the failure detail and the Conductor repair brief, and
309
+ // "one of them is wrong" gives the next run nothing to act on.
310
+ const detail = [
311
+ missing.length ? `missing: ${missing.map(quoteCriterion).join("; ")}` : "",
312
+ duplicated.length ? `reported more than once: ${duplicated.map(quoteCriterion).join("; ")}` : "",
313
+ ].filter(Boolean).join(" | ");
314
+ throw new Error(`Agent report must include every acceptance criterion exactly once (${detail})`);
229
315
  }
230
- return { ...parsed.data, acceptance_results: parsed.data.acceptance_results.map((item) => ({ ...item, evidence_artifact_ids: [] })) };
316
+ // Missing evidence mappings cannot support a "met" claim. Preserve the evidence itself, but
317
+ // downgrade only the unsupported result to unknown so the existing quality loop can assess the
318
+ // completed work instead of throwing the whole implementation away.
319
+ const mappedCriteria = new Set(parsed.data.evidence.flatMap((item) => item.acceptance_criteria.map(criterionKey)));
320
+ return {
321
+ ...parsed.data,
322
+ acceptance_results: parsed.data.acceptance_results.map((item) => {
323
+ const { unverified_reason: reported, unverified_detail: detail, ...rest } = item;
324
+ const status = item.status === "met" && !mappedCriteria.has(criterionKey(item.criterion))
325
+ ? "unknown"
326
+ : item.status;
327
+ return {
328
+ ...rest,
329
+ status,
330
+ // An undecided criterion must say why. A missing reason reads as `not_attempted` — the
331
+ // recoverable case — rather than failing the report: the implementation is finished by
332
+ // this point, and a delivery is not worth discarding over one absent field. A decided
333
+ // criterion carries no reason at all, so the packet holds no key that means nothing.
334
+ ...(status === "unknown"
335
+ ? {
336
+ unverified_reason: reported ?? "not_attempted",
337
+ ...(detail ? { unverified_detail: detail } : {}),
338
+ }
339
+ : {}),
340
+ evidence_artifact_ids: [],
341
+ };
342
+ }),
343
+ };
231
344
  }
232
345
  /**
233
346
  * Artifact publication receipts require `sha256:<64 hex>`. Agents often paste bare `shasum` output;
@@ -266,7 +379,11 @@ function sanitizeReportShape(raw) {
266
379
  const evidence = item;
267
380
  if ("details" in evidence)
268
381
  evidence.details = cleanList(evidence.details);
269
- if ("acceptance_criteria" in evidence)
382
+ // Experimental lanes have repeatedly omitted this list on otherwise-useful test evidence.
383
+ // An empty mapping asserts nothing; later logic downgrades any now-unsupported "met" result.
384
+ if (!("acceptance_criteria" in evidence) || evidence.acceptance_criteria == null)
385
+ evidence.acceptance_criteria = [];
386
+ else
270
387
  evidence.acceptance_criteria = cleanList(evidence.acceptance_criteria);
271
388
  // Agents emit null/"" for optional fields they have no value for — treat as absent.
272
389
  if ("digest" in evidence && (typeof evidence.digest !== "string" || evidence.digest.trim() === ""))
@@ -9,6 +9,24 @@ import { normalizeRepositoryUrl } from "./brief.js";
9
9
  const execFileAsync = promisify(execFile);
10
10
  /** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
11
11
  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;
12
+ /**
13
+ * Commits the attempt branch has and the base does not.
14
+ * Returns null when this checkout does not know the base. An unverifiable precondition must not
15
+ * stop a delivery that can be correct.
16
+ */
17
+ export async function commitsAheadOfBase(workspace, base) {
18
+ try {
19
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-list", "--count", `${base}..HEAD`], {
20
+ timeout: 30_000,
21
+ maxBuffer: 1_000_000,
22
+ });
23
+ const count = Number(stdout.trim());
24
+ return Number.isFinite(count) ? count : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
12
30
  export function needsPullRequest(report, spec, grants) {
13
31
  return Boolean(grants.includes("pr_create")
14
32
  && (spec.change_scope?.length ?? 0) > 0
@@ -160,6 +178,7 @@ export async function pushOriginHead(workspace, options = {}) {
160
178
  export async function ensureDeliveryPullRequest(input) {
161
179
  const readHead = input.readHeadCommit ?? workspaceHeadCommit;
162
180
  const push = input.pushOriginHead ?? pushOriginHead;
181
+ const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
163
182
  let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);
164
183
  if ((input.spec.change_scope?.length ?? 0) > 0) {
165
184
  const head = await readHead(input.workspace);
@@ -170,6 +189,17 @@ export async function ensureDeliveryPullRequest(input) {
170
189
  }
171
190
  if (!needsPullRequest(report, input.spec, input.grants))
172
191
  return report;
192
+ // A briefed report-only repair commits nothing, because its brief tells it not to touch source
193
+ // files. GitHub then answers the pull-request call with an opaque 422 "No commits between main
194
+ // and <branch>" (initiative efa56ccd, attempt 874fa0e3). Prove the branch carries work first, and
195
+ // report the real condition instead of a forge validation error.
196
+ const base = input.spec.repository?.base_commit;
197
+ if (base) {
198
+ const ahead = await countAhead(input.workspace, base);
199
+ if (ahead === 0) {
200
+ throw new Error(`The attempt branch has no commit after base ${base.slice(0, 12)}; this run produced no change to deliver`);
201
+ }
202
+ }
173
203
  await push(input.workspace);
174
204
  const headAfterPush = await readHead(input.workspace);
175
205
  if (!report.head_commit || !commitsMatch(report.head_commit, headAfterPush)) {
package/dist/execution.js CHANGED
@@ -912,7 +912,29 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
912
912
  catch (repairError) {
913
913
  parseError = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
914
914
  if (repairTurn >= maxRepairs) {
915
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair exhausted: ${parseError}`, retryable: false, idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}` } });
915
+ const detail = `Delivery report repair exhausted: ${parseError}`;
916
+ // Implementation finished; only its response envelope is invalid. Preserve the exact
917
+ // tree and identify this narrow condition so the control plane can prepare repair
918
+ // guidance instead of discarding the work and asking the owner to debug JSON.
919
+ retainAttemptWorktree = true;
920
+ const response = await queueTerminal(client, taskId, {
921
+ action: "fail",
922
+ body: {
923
+ error: detail,
924
+ retryable: false,
925
+ failure: {
926
+ code: "delivery_report_invalid",
927
+ class: "contract",
928
+ disposition: "rework",
929
+ responsible_party: "conduit",
930
+ message: "Conduit could not prepare a valid delivery report from the completed agent run.",
931
+ next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
932
+ diagnostic_detail: detail,
933
+ },
934
+ idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
935
+ },
936
+ });
937
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
916
938
  console.error(`Assignment ${taskId} exhausted its report-only repair: ${redactSecrets(parseError)}`);
917
939
  const replyTail = previousReply.slice(-8_000);
918
940
  console.error(`Assignment ${taskId} repaired reply tail (${previousReply.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {