@miraland-labs/conduit-bridge 0.16.113 → 0.16.115

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/cli.js CHANGED
@@ -655,13 +655,6 @@ async function runner() {
655
655
  await heartbeat(client, config, workspace, currentBrief, preflight, intentProof, bootstrapResult);
656
656
  }
657
657
  await renewLeases(client, config);
658
- if (workspace && onlineDriverIds(config).length) {
659
- // Observation runs before authoring: an owner waiting on an answer should not queue behind
660
- // a long delivery, and the control plane already counted this slot as busy.
661
- progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs, {
662
- managedRoot: hb.on_shift?.managed_workspace_root ?? null,
663
- }) || progressed;
664
- }
665
658
  if (workspace && onlineDriverIds(config).length) {
666
659
  // Read the workspace again, then publish it. The last brief stands if the read fails.
667
660
  const publishWorkspaceState = async (options = {}) => {
@@ -671,10 +664,18 @@ async function runner() {
671
664
  const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
672
665
  return { preflight, hb };
673
666
  };
667
+ const publishHeartbeat = async () => {
668
+ await publishWorkspaceState();
669
+ };
670
+ // Observation runs before authoring: an owner waiting on an answer should not queue behind
671
+ // a long delivery, and the control plane already counted this slot as busy.
672
+ progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs, {
673
+ managedRoot: hb.on_shift?.managed_workspace_root ?? null,
674
+ heartbeat: publishHeartbeat,
675
+ heartbeatIntervalMs: intervalMs,
676
+ }) || progressed;
674
677
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
675
- heartbeat: async () => {
676
- await publishWorkspaceState();
677
- },
678
+ heartbeat: publishHeartbeat,
678
679
  beforeClaim: async (driverId) => {
679
680
  // Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
680
681
  // Publish the fresh probe first; the Control Plane then remains the authority for the
@@ -17,9 +17,8 @@ import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from
17
17
  import { diffStatSince } from "./git-witness.js";
18
18
  import { switchManagedWorkspace } from "./on-shift-apply.js";
19
19
  import { execFile, spawn } from "node:child_process";
20
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
20
+ import { rm, writeFile } from "node:fs/promises";
21
21
  import { createRequire } from "node:module";
22
- import { tmpdir } from "node:os";
23
22
  import { join } from "node:path";
24
23
  import { promisify } from "node:util";
25
24
  import { isRunnableVerificationCommand } from "./execution-class.js";
@@ -97,9 +96,11 @@ const investigationBriefSchema = z.object({
97
96
  /** T122: a delivery verification is an investigation whose question carries this header. */
98
97
  export const DELIVERY_VERIFICATION_HEADER = "DELIVERY VERIFICATION";
99
98
  /** Owner-verbatim: the lane writes probe source; Bridge executes. Shown only when BRIEF KEYS are present. */
100
- export const DELIVERY_VERIFICATION_PROBE_INSTRUCTION = 'For every BRIEF KEY that states behaviour, add one entry to probes: either {"key":"[sN]","test":"<registered command that asserts it>","checks":"<one line: what it proves>"} or {"key":"[sN]","lang":"ts|py|sh","source":"<a short script that exits 0 only when the key holds at this commit and non-zero otherwise; it may import repository modules by absolute path from the working directory>","checks":"<one line>"}. You do not run probes; Bridge runs them after you finish. Keys that state no behaviour (wording, scope, docs) get no probe.';
99
+ export const DELIVERY_VERIFICATION_PROBE_INSTRUCTION = 'For every BRIEF KEY that states behaviour, add one entry to probes: either {"key":"[sN]","test":"<registered command that asserts it>","checks":"<one line: what it proves>"} or {"key":"[sN]","lang":"ts|py|sh","source":"<a short script run from the repository root that prints PROBE HOLDS as its last line and exits 0 when the key holds at this commit and prints PROBE FAILS as its last line and exits 1 when it does not; any other ending is recorded as broken; it may import repository modules by paths relative to the repository root>","checks":"<one line>"}. You do not run probes; Bridge runs them after you finish. Keys that state no behaviour (wording, scope, docs) get no probe.';
101
100
  const BRIEF_JSON_EXAMPLE = '{"summary":"one paragraph: what the change does and whether it meets the criteria","findings":["grounded fact with file/symbol"],"verification":["command — result"],"limitations":["what you could not settle"],"criteria":[{"criterion":"c1","assessment":"supported|unsupported|unclear","reason":"what you saw"}],"command_failures":["command that exited non-zero — the decisive output line"],"files_read":0,"bytes_read":0,"commands_run":["command you actually ran"]}';
102
101
  const BRIEF_JSON_EXAMPLE_WITH_PROBES = '{"summary":"one paragraph: what the change does and whether it meets the criteria","findings":["grounded fact with file/symbol"],"verification":["command — result"],"limitations":["what you could not settle"],"criteria":[{"criterion":"c1","assessment":"supported|unsupported|unclear","reason":"what you saw"}],"command_failures":["command that exited non-zero — the decisive output line"],"probes":[],"files_read":0,"bytes_read":0,"commands_run":["command you actually ran"]}';
102
+ /** Named in both prompts so the lane knows each list's maximum before it writes. */
103
+ export const BRIEF_LIMITS_SENTENCE = "Limits: findings at most 12, verification at most 8, limitations at most 8, criteria at most 40, command_failures at most 10, commands_run at most 10, probes at most 20; each text entry at most 1000 characters, commands_run entries at most 200 characters. Keep the most decisive entries.";
103
104
  export function isDeliveryVerification(assignment) {
104
105
  return assignment.question.trimStart().startsWith(DELIVERY_VERIFICATION_HEADER);
105
106
  }
@@ -121,6 +122,7 @@ export function buildInvestigationPrompt(assignment, commit, context) {
121
122
  "",
122
123
  "Return only one fenced ```json object with exactly this shape:",
123
124
  '{"summary":"direct answer to the question","findings":["grounded fact with file/symbol"],"verification":["command you actually ran"],"limitations":["what you could not settle"],"files_read":0,"bytes_read":0,"commands_run":["command you actually ran"]}',
125
+ BRIEF_LIMITS_SENTENCE,
124
126
  "summary is a string; the other list fields are arrays of strings. Close the fence and add no prose after it.",
125
127
  ].join("\n");
126
128
  }
@@ -164,6 +166,7 @@ export function buildDeliveryVerificationPrompt(assignment, commit, context) {
164
166
  "",
165
167
  "Return only one fenced ```json object with exactly this shape:",
166
168
  keys.length ? BRIEF_JSON_EXAMPLE_WITH_PROBES : BRIEF_JSON_EXAMPLE,
169
+ BRIEF_LIMITS_SENTENCE,
167
170
  keys.length
168
171
  ? "criteria has one entry per ACCEPTANCE key. probes has at most 20 entries, one per BRIEF KEY that states behaviour. Close the fence and add no prose after it."
169
172
  : "criteria has one entry per ACCEPTANCE key. Close the fence and add no prose after it.",
@@ -208,6 +211,83 @@ function settleCommandsRun(gates, reported) {
208
211
  export const UNUSABLE_BRIEF = "Investigation returned no usable brief";
209
212
  /** T130: how much of an unusable reply travels with the failure, so the next run can be diagnosed. */
210
213
  const UNUSABLE_BRIEF_TAIL_CHARS = 800;
214
+ const BRIEF_SUMMARY_MAX = 4_000;
215
+ const BRIEF_ITEM_MAX = 1_000;
216
+ const BRIEF_FILES_READ_MAX = 24;
217
+ const BRIEF_BYTES_READ_MAX = 512 * 1024;
218
+ const BRIEF_COMMAND_RUN_MAX = 200;
219
+ const BRIEF_LIST_MAX = {
220
+ findings: 12,
221
+ verification: 8,
222
+ limitations: 8,
223
+ criteria: 40,
224
+ command_failures: 10,
225
+ commands_run: 10,
226
+ probes: 20,
227
+ };
228
+ function clipBriefText(value, max) {
229
+ return value.length > max ? value.slice(0, max) : value;
230
+ }
231
+ function clipBriefStringField(record, key, max) {
232
+ const value = record[key];
233
+ if (typeof value === "string")
234
+ record[key] = clipBriefText(value, max);
235
+ }
236
+ function fitBriefStrings(items, max) {
237
+ return items.map((item) => typeof item === "string" ? clipBriefText(item, max) : item);
238
+ }
239
+ function fitCriteriaItems(items) {
240
+ return items.map((item) => {
241
+ if (!item || typeof item !== "object" || Array.isArray(item))
242
+ return item;
243
+ const row = { ...item };
244
+ clipBriefStringField(row, "criterion", BRIEF_ITEM_MAX);
245
+ clipBriefStringField(row, "reason", BRIEF_ITEM_MAX);
246
+ return row;
247
+ });
248
+ }
249
+ /**
250
+ * Cut an over-full brief to the contract before schema parse. Only size is fitted; a wrong type,
251
+ * a missing summary, or an unknown assessment still fails as today. Probe entries are kept as
252
+ * written — clipping them would change what Bridge later executes.
253
+ */
254
+ export function fitBriefToContract(candidate) {
255
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
256
+ return { fitted: candidate, cuts: [] };
257
+ }
258
+ const brief = { ...candidate };
259
+ const cuts = [];
260
+ const fitNamedList = (key, fitItems) => {
261
+ const value = brief[key];
262
+ if (!Array.isArray(value))
263
+ return;
264
+ const max = BRIEF_LIST_MAX[key];
265
+ if (value.length > max)
266
+ cuts.push({ list: key, kept: max, total: value.length });
267
+ brief[key] = fitItems(value.slice(0, max));
268
+ };
269
+ fitNamedList("findings", (items) => fitBriefStrings(items, BRIEF_ITEM_MAX));
270
+ fitNamedList("verification", (items) => fitBriefStrings(items, BRIEF_ITEM_MAX));
271
+ fitNamedList("limitations", (items) => fitBriefStrings(items, BRIEF_ITEM_MAX));
272
+ fitNamedList("criteria", fitCriteriaItems);
273
+ fitNamedList("command_failures", (items) => fitBriefStrings(items, BRIEF_ITEM_MAX));
274
+ fitNamedList("commands_run", (items) => fitBriefStrings(items, BRIEF_COMMAND_RUN_MAX));
275
+ fitNamedList("probes", (items) => items);
276
+ clipBriefStringField(brief, "summary", BRIEF_SUMMARY_MAX);
277
+ if (typeof brief.files_read === "number" && brief.files_read > BRIEF_FILES_READ_MAX) {
278
+ brief.files_read = BRIEF_FILES_READ_MAX;
279
+ }
280
+ if (typeof brief.bytes_read === "number" && brief.bytes_read > BRIEF_BYTES_READ_MAX) {
281
+ brief.bytes_read = BRIEF_BYTES_READ_MAX;
282
+ }
283
+ if (cuts.length) {
284
+ const notes = cuts.map((cut) => clipBriefText(`Bridge kept the first ${cut.kept} of ${cut.total} ${cut.list}`, BRIEF_ITEM_MAX));
285
+ const existing = Array.isArray(brief.limitations) ? [...brief.limitations] : [];
286
+ const room = Math.max(0, BRIEF_LIST_MAX.limitations - notes.length);
287
+ brief.limitations = [...existing.slice(0, room), ...notes];
288
+ }
289
+ return { fitted: brief, cuts };
290
+ }
211
291
  /**
212
292
  * T130: an unusable brief used to fail with five words. The first production verification retry
213
293
  * (2026-09-09, acme-sim / forge R20) returned "no usable brief" twice and nobody could say whether the
@@ -222,7 +302,7 @@ export function parseInvestigationBrief(text) {
222
302
  catch {
223
303
  throw new Error(`${UNUSABLE_BRIEF}: no JSON object in the reply. Reply tail: ${redactSecrets(boundedTail(text, UNUSABLE_BRIEF_TAIL_CHARS))}`);
224
304
  }
225
- const parsed = investigationBriefSchema.safeParse(candidate);
305
+ const parsed = investigationBriefSchema.safeParse(fitBriefToContract(candidate).fitted);
226
306
  if (!parsed.success) {
227
307
  const issues = parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
228
308
  throw new Error(`${UNUSABLE_BRIEF}: the JSON does not match the brief contract (${issues}). Reply tail: ${redactSecrets(boundedTail(text, UNUSABLE_BRIEF_TAIL_CHARS))}`);
@@ -354,11 +434,8 @@ function probeInterpreter(language) {
354
434
  return "bash";
355
435
  }
356
436
  function probeFilename(language) {
357
- if (language === "ts")
358
- return "probe.mts";
359
- if (language === "py")
360
- return "probe.py";
361
- return "probe.sh";
437
+ const extension = language === "ts" ? "mts" : language === "py" ? "py" : "sh";
438
+ return `.conduit-probe-${crypto.randomUUID()}.${extension}`;
362
439
  }
363
440
  function probeArgv(language, file) {
364
441
  if (language === "ts")
@@ -452,13 +529,11 @@ export async function runDeliveryVerificationProbes(input) {
452
529
  continue;
453
530
  const language = probe.lang;
454
531
  const command = `${probeInterpreter(language)} ${probe.key}`;
455
- let dir = null;
532
+ const file = join(input.workspace, probeFilename(language));
456
533
  let exitCode = -1;
457
534
  let stdout = "";
458
535
  let stderr = "";
459
536
  try {
460
- dir = await mkdtemp(join(tmpdir(), "conduit-probe-"));
461
- const file = join(dir, probeFilename(language));
462
537
  await writeFile(file, source, { encoding: "utf8" });
463
538
  const result = await execProbe(probeArgv(language, file), input.workspace);
464
539
  exitCode = result.code;
@@ -470,8 +545,7 @@ export async function runDeliveryVerificationProbes(input) {
470
545
  exitCode = -1;
471
546
  }
472
547
  finally {
473
- if (dir)
474
- await rm(dir, { recursive: true, force: true }).catch(() => undefined);
548
+ await rm(file, { force: true }).catch(() => undefined);
475
549
  }
476
550
  witnesses.push(witnessRecord({
477
551
  key: probe.key, kind: "probe", command, exitCode, stdout, stderr, checks,
@@ -495,11 +569,12 @@ const deliveredWitnessSchema = z.object({
495
569
  function stampInstrument(witnesses, instrument) {
496
570
  return witnesses.map((witness) => ({ ...witness, instrument }));
497
571
  }
498
- function markSelfCheckBroken(witnesses) {
572
+ function markSelfCheckBroken(witnesses, reason) {
573
+ const tail = boundedTail(`${DELIVERED_PROBE_SELF_CHECK_TAIL}: ${reason}`, PROBE_TAIL_CHARS);
499
574
  return stampInstrument(witnesses.map((witness) => ({
500
575
  ...witness,
501
576
  verdict: "broken",
502
- tail: DELIVERED_PROBE_SELF_CHECK_TAIL,
577
+ tail,
503
578
  })), "running");
504
579
  }
505
580
  function toStepWitness(witness) {
@@ -569,16 +644,18 @@ export async function runDeliveryVerificationProbesForAttempt(input) {
569
644
  return stampInstrument(await runRunning(), "running");
570
645
  }
571
646
  const runDelivered = input.runDeliveredProbes ?? ((args) => runDeliveredProbeInstrument(args));
647
+ let selfCheckDetail = null;
572
648
  try {
573
649
  const check = await runDelivered({ workspace: input.workspace, probes: KNOWN_ANSWER_PROBES });
574
650
  if (knownAnswerProbesPassed(check)) {
575
651
  return stampInstrument(await runDelivered({ workspace: input.workspace, probes: input.probes }), "delivered");
576
652
  }
653
+ selfCheckDetail = `gave ${check.map((witness) => witness.verdict).join(", ")}`;
577
654
  }
578
- catch {
579
- // Known-answer failure and a delivered-instrument throw take the same path: running probes, then broken.
655
+ catch (error) {
656
+ selfCheckDetail = redactSecrets(error instanceof Error ? error.message : String(error));
580
657
  }
581
- return markSelfCheckBroken(await runRunning());
658
+ return markSelfCheckBroken(await runRunning(), selfCheckDetail ?? "");
582
659
  }
583
660
  async function settle(client, investigationId, body) {
584
661
  await client.request(`/runner/v1/investigations/${investigationId}/settle`, {
@@ -675,6 +752,19 @@ export async function executeNextInvestigation(client, config, workspace, brief,
675
752
  }).catch((error) => console.error(`Investigation ${assignment.id} lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
676
753
  }, options.renewIntervalMs ?? INVESTIGATION_RENEW_INTERVAL_MS);
677
754
  renewTimer.unref?.();
755
+ let heartbeatRunning = false;
756
+ const heartbeat = options.heartbeat;
757
+ const heartbeatTimer = heartbeat
758
+ ? setInterval(() => {
759
+ if (heartbeatRunning)
760
+ return;
761
+ heartbeatRunning = true;
762
+ void heartbeat()
763
+ .catch((error) => console.error(`Heartbeat failed during investigation: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`))
764
+ .finally(() => { heartbeatRunning = false; });
765
+ }, options.heartbeatIntervalMs ?? 15_000)
766
+ : null;
767
+ heartbeatTimer?.unref?.();
678
768
  try {
679
769
  attemptWorkspace = await createAttemptWorktree({
680
770
  sourceWorkspace: workspace,
@@ -795,6 +885,8 @@ export async function executeNextInvestigation(client, config, workspace, brief,
795
885
  }
796
886
  finally {
797
887
  clearInterval(renewTimer);
888
+ if (heartbeatTimer)
889
+ clearInterval(heartbeatTimer);
798
890
  if (attemptWorkspace) {
799
891
  await removeAttemptWorktree(workspace, attemptWorkspace)
800
892
  .catch((error) => console.error(`Investigation worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.113",
3
+ "version": "0.16.115",
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": {