@miraland-labs/conduit-bridge 0.14.6 → 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,21 +299,47 @@ 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
316
  // Missing evidence mappings cannot support a "met" claim. Preserve the evidence itself, but
231
317
  // downgrade only the unsupported result to unknown so the existing quality loop can assess the
232
318
  // completed work instead of throwing the whole implementation away.
233
- const mappedCriteria = new Set(parsed.data.evidence.flatMap((item) => item.acceptance_criteria));
319
+ const mappedCriteria = new Set(parsed.data.evidence.flatMap((item) => item.acceptance_criteria.map(criterionKey)));
234
320
  return {
235
321
  ...parsed.data,
236
- acceptance_results: parsed.data.acceptance_results.map((item) => ({
237
- ...item,
238
- status: item.status === "met" && !mappedCriteria.has(item.criterion) ? "unknown" : item.status,
239
- evidence_artifact_ids: [],
240
- })),
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
+ }),
241
343
  };
242
344
  }
243
345
  /**
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.14.6",
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": {