@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 +126 -24
- package/dist/ensure-pull-request.js +30 -0
- package/package.json +1 -1
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.",
|
|
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
|
|
144
|
-
* then an unclosed
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
239
|
-
|
|
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.
|
|
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": {
|