balladeer 1.0.19 → 1.0.21

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.
Files changed (44) hide show
  1. package/README.md +18 -0
  2. package/dist/anchoring.js +2 -2
  3. package/dist/anchors-schema.d.ts +3 -1
  4. package/dist/anchors-schema.js +5 -3
  5. package/dist/commands/judge.d.ts +50 -1
  6. package/dist/commands/judge.js +768 -110
  7. package/dist/commands/mcp.d.ts +4 -0
  8. package/dist/commands/mcp.js +24 -1
  9. package/dist/currency.js +9 -3
  10. package/dist/guidance-hook.mjs +13 -3
  11. package/dist/headless-agent.d.ts +2 -0
  12. package/dist/headless-agent.js +5 -1
  13. package/dist/judge-brief.d.ts +11 -5
  14. package/dist/judge-brief.js +26 -10
  15. package/dist/judge-evidence.d.ts +87 -0
  16. package/dist/judge-evidence.js +393 -0
  17. package/dist/judge-hook.d.ts +14 -1
  18. package/dist/judge-hook.js +34 -5
  19. package/dist/judge-review.d.ts +10 -0
  20. package/dist/judge-review.js +36 -0
  21. package/dist/judge-said.d.ts +1 -0
  22. package/dist/judge-said.js +3 -0
  23. package/dist/promise-meaning.d.ts +4 -0
  24. package/dist/promise-review-client.d.ts +84 -0
  25. package/dist/promise-review-client.js +357 -0
  26. package/dist/push-operation.d.ts +61 -0
  27. package/dist/push-operation.js +472 -0
  28. package/dist/read-only-review.d.ts +2 -0
  29. package/dist/read-only-review.js +2 -0
  30. package/dist/review-backstop.d.ts +20 -0
  31. package/dist/review-backstop.js +54 -0
  32. package/dist/review-reporting.d.ts +32 -0
  33. package/dist/review-reporting.js +319 -0
  34. package/dist/review-selection.d.ts +33 -0
  35. package/dist/review-selection.js +64 -0
  36. package/dist/review-settings.d.ts +6 -0
  37. package/dist/review-settings.js +9 -0
  38. package/dist/self-update.d.ts +9 -0
  39. package/dist/self-update.js +35 -1
  40. package/dist/user-scope.d.ts +1 -10
  41. package/dist/user-scope.js +12 -7
  42. package/dist/wire.d.ts +3 -3
  43. package/dist/wire.js +1 -1
  44. package/package.json +1 -1
@@ -1,13 +1,21 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
4
  import { BEHAVIOR_MAP_DIRECTORY, CASE_ID, PROMISE_ID, parseBehaviorMap, } from "../behavior-map-schema.js";
4
5
  import { runCommand } from "../gh.js";
5
6
  import { repositoryRoot } from "../git.js";
6
7
  import { agentEnvironment, resolveClient, runHeadlessAgent, JUDGE_ACTIVE_VARIABLE, } from "../headless-agent.js";
7
8
  import { JUDGE_BRIEF_REVISION, judgePrompt } from "../judge-brief.js";
8
- import { baseBeforePush, checkoutRoot, commitBeforePush, effectiveBudgetSeconds, hasJudgeEntry, hasProjectJudgeHook, hookResponse, installJudgeHook, isPushCommand, mergeTargets, parseHookInput, parsePrePushInput, pushCheckOff, pushLanded, pushPlace, remoteRepositoryNames, repositoryName, sendsCommits, setPushCheck, } from "../judge-hook.js";
9
+ import { codeReviewLines, readCodeReview } from "../judge-review.js";
10
+ import { executeFreshReproduction, executeKeptEvidence, freezeEvidenceFiles, reviewExecutedEvidence, } from "../judge-evidence.js";
11
+ import { catalogReaders, connectionReviewsClient, } from "../promise-review-client.js";
12
+ import { reviewBackstop } from "../review-backstop.js";
13
+ import { targetedRiskCandidates } from "../review-selection.js";
14
+ import { capturePushOperation, consumePushOperation, } from "../push-operation.js";
15
+ import { checkoutRoot, commitBeforePush, effectiveBudgetSeconds, hasJudgeEntry, hasProjectJudgeHook, hookResponse, installJudgeHook, isPushCommand, mergeTargets, parseHookInput, parsePrePushInput, pushCheckOff, pushPlace, remoteRepositoryNames, repositoryName, sendsCommits, setPushCheck, } from "../judge-hook.js";
9
16
  import { noteHookFired } from "../hook-trust.js";
10
17
  import { sessionMemory } from "../judge-said.js";
18
+ import { deliverReviews, reviewPayload } from "../review-reporting.js";
11
19
  import { runOwnedProcess, tail } from "../owned-process.js";
12
20
  import { caseIds, connectionReader, meaningExcerpt, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
13
21
  import { addScratchWorktree, carryFiles, newFiles, removeLiveWorktreesNow, resolveCommit, shareDependencies, } from "../scratch-worktree.js";
@@ -15,7 +23,7 @@ import { normalizeControlPlane, StoreError } from "../store.js";
15
23
  import { directness, riskOrder } from "../risk/score.js";
16
24
  import { CLI_INVOCATION, DEFAULT_CONTROL_PLANE } from "../wire.js";
17
25
  import { AGENT_TOOLS, REFUSED_TOOL_RULES, agentFailure, readBehaviorMap, readBehaviorMaps, } from "./map.js";
18
- import { offersForPush } from "./offers.js";
26
+ import { isRepairChange, offersForPush } from "./offers.js";
19
27
  /**
20
28
  * `balladeer judge`: for a change about to leave this machine, ask the person's
21
29
  * own coding agent, one promise at a time, whether the change keeps it.
@@ -275,7 +283,20 @@ export function keepReproduction(input) {
275
283
  judgeLogDirectory(input.root);
276
284
  rmSync(staging, { recursive: true, force: true });
277
285
  mkdirSync(staging, { recursive: true });
278
- const kept = carryFiles(input.headTree, join(staging, "files"), input.files);
286
+ const kept = input.frozenFiles === undefined
287
+ ? carryFiles(input.headTree, join(staging, "files"), input.files)
288
+ : input.frozenFiles.map((file) => {
289
+ const destination = insideDirectory(join(staging, "files"), file.path);
290
+ if (destination === undefined ||
291
+ createHash("sha256").update(file.bytes).digest("hex") !== file.sha256)
292
+ throw new Error("Frozen evidence validation failed");
293
+ mkdirSync(dirname(destination), { recursive: true });
294
+ writeFileSync(destination, file.bytes, { flag: "wx" });
295
+ return { path: file.path, sha256: file.sha256 };
296
+ });
297
+ if (input.frozenFiles !== undefined &&
298
+ sha256Digest({ command: input.command, cwd: input.cwd, files: kept }) !== input.digest)
299
+ throw new Error("Stored evidence does not match executed evidence");
279
300
  const manifest = {
280
301
  schemaVersion: KEPT_REPRODUCTION_SCHEMA_VERSION,
281
302
  promiseId: input.promiseId,
@@ -336,6 +357,11 @@ export function readJudgeAnswer(json) {
336
357
  const verdict = json.verdict;
337
358
  if (verdict !== "kept" && verdict !== "broken" && verdict !== "could_not_tell")
338
359
  return undefined;
360
+ if (verdict !== "could_not_tell" &&
361
+ (!Array.isArray(json.evidenceFiles) ||
362
+ json.evidenceFiles.length > 200 ||
363
+ !json.evidenceFiles.every((file) => typeof file === "string")))
364
+ return undefined;
339
365
  const raw = json.reproduction;
340
366
  const reproduction = raw !== null &&
341
367
  typeof raw === "object" &&
@@ -357,7 +383,27 @@ export function readJudgeAnswer(json) {
357
383
  ...(reproduction === undefined ? {} : { reproduction }),
358
384
  exercisedCases,
359
385
  notes: typeof json.notes === "string" ? json.notes.slice(0, 2000) : "",
386
+ ...(readCodeReview(json.review) === undefined ? {} : { review: readCodeReview(json.review) }),
360
387
  ...(nextStep === "" ? {} : { nextStep }),
388
+ evidenceFiles: Array.isArray(json.evidenceFiles) &&
389
+ json.evidenceFiles.every((file) => typeof file === "string")
390
+ ? json.evidenceFiles
391
+ : [],
392
+ caseEvidence: Array.isArray(json.caseEvidence)
393
+ ? json.caseEvidence.flatMap((row) => {
394
+ if (row === null || typeof row !== "object")
395
+ return [];
396
+ const item = row;
397
+ return typeof item.command === "string" &&
398
+ item.command.trim() !== "" &&
399
+ item.command.length <= 2000 &&
400
+ typeof item.cwd === "string" &&
401
+ Array.isArray(item.caseIds) &&
402
+ item.caseIds.every((id) => typeof id === "string" && CASE_ID.test(id))
403
+ ? [{ command: item.command, cwd: item.cwd, caseIds: item.caseIds }]
404
+ : [];
405
+ })
406
+ : [],
361
407
  };
362
408
  }
363
409
  /**
@@ -369,40 +415,56 @@ export function readJudgeAnswer(json) {
369
415
  */
370
416
  export async function judgePromise(input) {
371
417
  const timeoutMs = input.timeoutMs ?? JUDGE_CAP_MS;
418
+ const deadline = Date.now() + timeoutMs;
419
+ const ownBudget = new AbortController();
420
+ const signal = input.signal === undefined
421
+ ? ownBudget.signal
422
+ : AbortSignal.any([input.signal, ownBudget.signal]);
423
+ const timer = setTimeout(() => ownBudget.abort(), timeoutMs);
424
+ const remaining = () => Math.max(1, deadline - Date.now());
372
425
  const tree = await addScratchWorktree(input.root, input.head, `judge-${input.promise.promiseId}`, input.scratch);
373
- if (!tree.ok)
426
+ if (!tree.ok) {
427
+ clearTimeout(timer);
374
428
  return { status: "not_judged", reason: tree.reason };
429
+ }
375
430
  const headTree = tree.worktree;
376
431
  try {
377
432
  await shareDependencies(input.root, headTree.path);
378
433
  let linkedTestRun = { file: "", outcome: "none" };
379
434
  const linkedTestRuns = [];
380
- const firstTest = input.map !== undefined ? input.map.linkedTests[0] : input.anchors?.linkedTests[0];
381
- if (firstTest !== undefined) {
382
- const command = linkedTestCommand(headTree.path, firstTest.file);
383
- if (command === undefined)
384
- linkedTestRun = { file: firstTest.file, outcome: "none" };
385
- else {
435
+ const linkedTests = input.map?.linkedTests ?? input.anchors?.linkedTests ?? [];
436
+ // Linked checks provide a baseline, but must leave time for the model's
437
+ // actual review. The agent can run additional checks when they resolve a question.
438
+ const linkedDeadline = Date.now() + Math.min(30_000, remaining() * 0.1);
439
+ const seenTests = new Set();
440
+ for (const test of linkedTests) {
441
+ if (Date.now() >= linkedDeadline)
442
+ break;
443
+ if (seenTests.has(test.file))
444
+ continue;
445
+ seenTests.add(test.file);
446
+ const command = linkedTestCommand(headTree.path, test.file);
447
+ let outcome = "none";
448
+ let output = "";
449
+ if (command !== undefined) {
386
450
  const run = await runShell(command, headTree.path, {
387
451
  env: input.env,
388
- timeoutMs: Math.min(LINKED_TEST_TIMEOUT_MS, timeoutMs),
389
- ...(input.signal === undefined ? {} : { signal: input.signal }),
452
+ timeoutMs: Math.max(1, Math.min(LINKED_TEST_TIMEOUT_MS, linkedDeadline - Date.now())),
453
+ signal,
390
454
  });
391
455
  if (run.aborted)
392
456
  return { status: "stopped" };
393
- const outcome = run.timedOut || run.exitCode === null || run.exitCode === 127
394
- ? "error"
395
- : run.exitCode === 0
396
- ? "pass"
397
- : "fail";
398
- linkedTestRun = { file: firstTest.file, outcome };
399
- linkedTestRuns.push({
400
- file: firstTest.file,
401
- command,
402
- outcome,
403
- output: tail(run.output, 800),
404
- });
457
+ outcome =
458
+ run.timedOut || run.exitCode === null || run.exitCode === 127
459
+ ? "error"
460
+ : run.exitCode === 0
461
+ ? "pass"
462
+ : "fail";
463
+ output = tail(run.output, 800);
405
464
  }
465
+ if (seenTests.size === 1)
466
+ linkedTestRun = { file: test.file, outcome };
467
+ linkedTestRuns.push({ file: test.file, command: command ?? "", outcome, output });
406
468
  }
407
469
  const agent = await (input.runAgent ?? runHeadlessAgent)({
408
470
  client: input.login.client,
@@ -418,15 +480,15 @@ export async function judgePromise(input) {
418
480
  base: input.base,
419
481
  head: input.head,
420
482
  linkedTestRuns,
421
- minutes: Math.max(1, Math.floor(timeoutMs / 60_000)),
483
+ minutes: Math.max(0.01, Math.round((remaining() / 60_000) * 100) / 100),
422
484
  }),
423
485
  allowedTools: AGENT_TOOLS,
424
486
  disallowedTools: REFUSED_TOOL_RULES,
425
487
  sandbox: "workspace-write",
426
- timeoutMs,
488
+ timeoutMs: remaining(),
427
489
  env: input.env,
428
490
  jsonOutput: true,
429
- ...(input.signal === undefined ? {} : { signal: input.signal }),
491
+ signal,
430
492
  ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
431
493
  });
432
494
  if (agent.aborted)
@@ -440,6 +502,7 @@ export async function judgePromise(input) {
440
502
  inputTokens: agent.inputTokens ?? 0,
441
503
  outputTokens: agent.outputTokens ?? 0,
442
504
  };
505
+ const answer = agent.ok ? readJudgeAnswer(agent.json) : undefined;
443
506
  const verdict = (fields) => ({
444
507
  schemaVersion: JUDGE_VERDICT_SCHEMA_VERSION,
445
508
  promiseId: input.promise.promiseId,
@@ -451,14 +514,15 @@ export async function judgePromise(input) {
451
514
  reproductionRejected: null,
452
515
  exercisedCases: [],
453
516
  notes: "",
517
+ ...(answer?.review === undefined ? {} : { review: answer.review }),
454
518
  judge,
455
519
  briefRevision: JUDGE_BRIEF_REVISION,
456
520
  mapUsed: input.map !== undefined,
457
521
  anchorsUsed: input.map === undefined && input.anchors !== undefined,
458
522
  linkedTestRun,
523
+ ...(input.promise.standing === undefined ? {} : { standing: input.promise.standing }),
459
524
  ...fields,
460
525
  });
461
- const answer = agent.ok ? readJudgeAnswer(agent.json) : undefined;
462
526
  if (answer === undefined)
463
527
  return {
464
528
  status: "judged",
@@ -466,22 +530,82 @@ export async function judgePromise(input) {
466
530
  notes: agent.ok ? "the judge's answer was not a verdict" : agentFailure(agent),
467
531
  }),
468
532
  };
533
+ if (answer.verdict !== "could_not_tell" && answer.review === undefined)
534
+ return {
535
+ status: "judged",
536
+ verdict: verdict({
537
+ notes: `The judge did not provide a code review grounded in inspected files. ${answer.notes}`.trim(),
538
+ }),
539
+ };
540
+ const frozen = await freezeEvidenceFiles(headTree.path, answer.evidenceFiles);
541
+ if (!frozen.ok)
542
+ return {
543
+ status: "judged",
544
+ verdict: verdict({ notes: `Evidence was refused: ${frozen.reason}.` }),
545
+ };
546
+ const evidenceInput = {
547
+ root: input.root,
548
+ sourceTree: headTree.path,
549
+ head: input.head,
550
+ manifest: frozen.manifest,
551
+ env: input.env,
552
+ timeoutMs: Math.min(REPRODUCTION_TIMEOUT_MS, remaining()),
553
+ signal,
554
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
555
+ };
556
+ const boundedReceipts = (receipts) => receipts.map(({ output: _output, ...receipt }) => receipt);
557
+ const review = async (claim, receipts) => reviewExecutedEvidence({
558
+ ...evidenceInput,
559
+ timeoutMs: Math.min(120_000, remaining()),
560
+ promise: input.promise,
561
+ base: input.base,
562
+ claim,
563
+ ...(answer.review === undefined ? {} : { review: answer.review }),
564
+ receipts,
565
+ login: input.login,
566
+ ...(input.runAgent === undefined ? {} : { runAgent: input.runAgent }),
567
+ });
469
568
  if (answer.verdict === "kept") {
470
- const missing = caseIds(input.promise).passing.filter((id) => !answer.exercisedCases.includes(id));
471
- if (missing.length > 0)
569
+ const executed = await executeKeptEvidence({
570
+ ...evidenceInput,
571
+ caseEvidence: answer.caseEvidence,
572
+ passingCaseIds: caseIds(input.promise).passing,
573
+ });
574
+ if (executed.stopped)
575
+ return { status: "stopped" };
576
+ if (executed.rejected !== null)
472
577
  return {
473
578
  status: "judged",
474
579
  verdict: verdict({
475
- exercisedCases: answer.exercisedCases,
476
- notes: `The judge answered kept without exercising ${missing.join(", ")}. ${answer.notes}`.trim(),
580
+ notes: `The judge's kept evidence was not established: ${executed.rejected}. ${answer.notes}`.trim(),
581
+ evidenceReceipts: boundedReceipts(executed.receipts),
477
582
  }),
478
583
  };
584
+ const assessed = await review("kept", executed.receipts);
585
+ if (assessed.stopped)
586
+ return { status: "stopped" };
479
587
  return {
480
588
  status: "judged",
481
589
  verdict: verdict({
482
- verdict: "kept",
483
- exercisedCases: answer.exercisedCases,
484
- notes: answer.notes,
590
+ verdict: assessed.accepted ? "kept" : "could_not_tell",
591
+ exercisedCases: assessed.accepted
592
+ ? [...new Set(executed.receipts.flatMap((receipt) => receipt.caseIds))]
593
+ : [],
594
+ evidenceReceipts: boundedReceipts(executed.receipts),
595
+ independentReview: {
596
+ accepted: assessed.accepted,
597
+ reason: assessed.reason,
598
+ ...(assessed.agent === undefined
599
+ ? {}
600
+ : {
601
+ durationMs: assessed.agent.durationMs,
602
+ inputTokens: assessed.agent.inputTokens ?? 0,
603
+ outputTokens: assessed.agent.outputTokens ?? 0,
604
+ }),
605
+ },
606
+ notes: assessed.accepted
607
+ ? answer.notes
608
+ : `The independent evidence review could not establish kept: ${assessed.reason}.`,
485
609
  }),
486
610
  };
487
611
  }
@@ -499,19 +623,24 @@ export async function judgePromise(input) {
499
623
  : {}),
500
624
  }),
501
625
  };
502
- const check = await verifyReproduction({
503
- root: input.root,
504
- headTree: headTree.path,
505
- head: input.head,
626
+ const executed = await executeFreshReproduction({
627
+ ...evidenceInput,
506
628
  base: input.base,
507
629
  command: answer.reproduction.command,
508
630
  cwd: answer.reproduction.cwd,
509
- env: input.env,
510
- ...(input.signal === undefined ? {} : { signal: input.signal }),
511
- ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
512
631
  });
513
- if (check.stopped)
632
+ if (executed.stopped)
633
+ return { status: "stopped" };
634
+ const assessed = executed.rejected === null ? await review("broken", executed.receipts) : undefined;
635
+ if (assessed?.stopped)
514
636
  return { status: "stopped" };
637
+ const check = {
638
+ ...executed,
639
+ rejected: executed.rejected ??
640
+ (assessed?.accepted === true
641
+ ? null
642
+ : `independent evidence review rejected the claim: ${assessed?.reason ?? "no assessment"}`),
643
+ };
515
644
  // Only a break that held up both ways is worth keeping: it is the start
516
645
  // of a lasting test, and a rejected one would be the start of a wrong one.
517
646
  const kept = check.rejected === null && check.failsOnHead && check.passesOnBase
@@ -526,6 +655,7 @@ export async function judgePromise(input) {
526
655
  cwd: answer.reproduction.cwd,
527
656
  digest: check.digest,
528
657
  files: check.files,
658
+ frozenFiles: frozen.manifest.files,
529
659
  })
530
660
  : undefined;
531
661
  const reproduction = {
@@ -542,36 +672,60 @@ export async function judgePromise(input) {
542
672
  verdict: check.rejected === null ? "broken" : "could_not_tell",
543
673
  reproduction,
544
674
  reproductionRejected: check.rejected,
675
+ evidenceReceipts: boundedReceipts(check.receipts),
676
+ ...(assessed === undefined
677
+ ? {}
678
+ : {
679
+ independentReview: {
680
+ accepted: assessed.accepted,
681
+ reason: assessed.reason,
682
+ ...(assessed.agent === undefined
683
+ ? {}
684
+ : {
685
+ durationMs: assessed.agent.durationMs,
686
+ inputTokens: assessed.agent.inputTokens ?? 0,
687
+ outputTokens: assessed.agent.outputTokens ?? 0,
688
+ }),
689
+ },
690
+ }),
545
691
  exercisedCases: answer.exercisedCases,
546
692
  notes: answer.notes,
547
693
  }),
548
694
  };
549
695
  }
550
696
  finally {
697
+ clearTimeout(timer);
551
698
  await headTree.remove();
552
699
  }
553
700
  }
554
701
  export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--top <k>] [--base <ref>] [--head <ref>]
555
- [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]
702
+ [--report-only | --block] [--wait] [--retry] [--json] [--client auto|claude|codex]
556
703
  [--from-file <promise.json> [--map <map.json>]]
557
704
  [--hook claude|codex|git] [--install-hook [claude|codex|git]]
558
705
  [--push-check on|off]
559
706
  Judge whether this change keeps the promises it could break, one promise
560
- at a time, on your own Claude Code or Codex login. A break counts only
707
+ at a time, on your own Claude Code or Codex login. Recorded meaning is
708
+ advisory and never blocks. Kept requires executable passing-case evidence
709
+ and a fresh agent assessment. A break counts only
561
710
  with a command that fails with the change and passes without it, and
562
- this command runs it both ways itself; anything unproven is "could not
711
+ this command runs it in fresh checkouts and a second local agent checks
712
+ that its assertion actually expresses the promise; anything unproven is "could not
563
713
  tell". Report mode prints one line per promise and never stops anything;
564
714
  --block exits 2 on a reproduced break, and when judges are still running
565
715
  at the time limit it holds the push rather than letting it through.
566
716
  --wait runs without the time limit. Promises come from --promises, else
567
- every agreed promise whose anchored code the change edits, and every
717
+ every visible recorded or agreed promise whose anchored code the change edits, and every
568
718
  promise with a behavior map whose own code or tests it edits (with
569
719
  "select": "ranked" in .continuity/judge.json, the top of the maps'
570
720
  ranking and a small random sample); plus that file's alwaysJudge. A
721
+ remaining current promise receives a bounded local relevance reading; strong
722
+ resource, sink and intent signals can add a promise beyond direct files. A
571
723
  promise with no anchors yet is anchored first, by one small call on
572
724
  your own login (see \`balladeer anchor\`), at most 20 a day on this
573
725
  laptop, and a line says what that cost. Verdicts are kept in
574
- .continuity/judge-log/, which git ignores. --install-hook adds the push
726
+ .continuity/judge-log/, which git ignores. Coverage and pending work are
727
+ logged separately; --retry retries an inconclusive result. A known pushed
728
+ branch also sends a bounded receipt to Balladeer, with no source or logs. --install-hook adds the push
575
729
  hook to .claude/settings.json and .codex/hooks.json, before and after
576
730
  each shell command: report mode judges after a push has landed, so the
577
731
  push never waits, and block mode judges before it. With git it writes
@@ -581,7 +735,7 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
581
735
  whoever is pushing; it records nothing and never holds a push (see
582
736
  \`balladeer offers\`). Setup also writes the same pair of entries at
583
737
  each host's user scope, running \`judge --hook <host> --user-scope\`,
584
- so the check runs in every connected repository with agreed promises
738
+ so the check runs in every connected repository with visible promises
585
739
  and says nothing anywhere else; a checkout with its own entry keeps
586
740
  the check to that entry. --push-check off turns the user-scope check
587
741
  off on this laptop, and --push-check on turns it back on.
@@ -595,6 +749,7 @@ export function parseJudgeArguments(argv) {
595
749
  let json = false;
596
750
  let mode;
597
751
  let wait = false;
752
+ let retry = false;
598
753
  let hook;
599
754
  let userScope = false;
600
755
  let pushCheck;
@@ -649,6 +804,8 @@ export function parseJudgeArguments(argv) {
649
804
  mode = "block";
650
805
  else if (name === "--wait")
651
806
  wait = true;
807
+ else if (name === "--retry")
808
+ retry = true;
652
809
  else if (name === "--hook")
653
810
  hook = host(name, value(name, inline));
654
811
  else if (name === "--user-scope")
@@ -697,6 +854,7 @@ export function parseJudgeArguments(argv) {
697
854
  json,
698
855
  ...(mode === undefined ? {} : { mode }),
699
856
  wait,
857
+ ...(retry ? { retry: true } : {}),
700
858
  ...(hook === undefined ? {} : { hook }),
701
859
  ...(userScope ? { userScope: true } : {}),
702
860
  ...(pushCheck === undefined ? {} : { pushCheck }),
@@ -1004,7 +1162,10 @@ export function findLoggedVerdict(root, key, now) {
1004
1162
  verdict.semanticDigest === key.semanticDigest &&
1005
1163
  verdict.base === key.base &&
1006
1164
  verdict.head === key.head &&
1007
- verdict.briefRevision === JUDGE_BRIEF_REVISION)
1165
+ verdict.briefRevision === JUDGE_BRIEF_REVISION &&
1166
+ (key.environmentDigest === undefined ||
1167
+ verdict.environmentDigest === key.environmentDigest) &&
1168
+ (!key.retry || verdict.verdict !== "could_not_tell"))
1008
1169
  return verdict;
1009
1170
  }
1010
1171
  catch {
@@ -1014,9 +1175,22 @@ export function findLoggedVerdict(root, key, now) {
1014
1175
  }
1015
1176
  return undefined;
1016
1177
  }
1017
- function sentence(text, limit = 240) {
1178
+ /**
1179
+ * A judge's words, shortened to fit one line an agent relays. A cut ends on a
1180
+ * whole sentence, or failing that a whole word, and says where the rest is:
1181
+ * the agent repeats the line as it reads it, so a reason cut mid-word reached
1182
+ * the person mid-word (found 1 October 2026).
1183
+ */
1184
+ export function sentence(text, limit = 240) {
1018
1185
  const flat = text.replace(/\s+/g, " ").trim();
1019
- return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
1186
+ if (flat.length <= limit)
1187
+ return flat;
1188
+ const head = flat.slice(0, limit);
1189
+ const end = Math.max(head.lastIndexOf(". "), head.lastIndexOf("? "), head.lastIndexOf("! "));
1190
+ const kept = end >= limit / 3
1191
+ ? head.slice(0, end + 1)
1192
+ : `${head.slice(0, Math.max(head.lastIndexOf(" "), 1)).replace(/[,;:]$/, "")}...`;
1193
+ return `${kept} (The rest is in ${JUDGE_LOG_DIRECTORY}.)`;
1020
1194
  }
1021
1195
  /** Said after "fix the behavior": a deliberate change is the person's, and the promise its owner's. */
1022
1196
  export const DELIBERATE_CHANGE = "If the person changed this behavior on purpose, do not undo their change: tell them it breaks this promise, whose meaning only its owner can change.";
@@ -1084,16 +1258,18 @@ export function stillKept(root, verdict) {
1084
1258
  * carries a second, indented line saying what to do next.
1085
1259
  */
1086
1260
  export function verdictLine(verdict, title, context = {}) {
1087
- const name = `${title} (${verdict.promiseId})`;
1261
+ const name = `${title} (${verdict.promiseId})${verdict.standing === "recorded" ? " [recorded intent; advisory]" : ""}`;
1088
1262
  const earlier = context.reused ? " [judged earlier for this commit]" : "";
1263
+ const review = codeReviewLines(verdict.review);
1264
+ const reviewSuffix = review ? `\n${review}` : "";
1089
1265
  if (verdict.verdict === "kept")
1090
- return `kept: ${name}${earlier}`;
1266
+ return `kept: ${name}${earlier}${reviewSuffix}`;
1091
1267
  if (verdict.verdict === "broken") {
1092
1268
  const where = verdict.reproduction && verdict.reproduction.cwd !== "."
1093
1269
  ? ` (in ${verdict.reproduction.cwd})`
1094
1270
  : "";
1095
- return (`broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}\n` +
1096
- ` ${afterBreakLine(verdict, context.checkBound)}`);
1271
+ return (`${verdict.standing === "recorded" ? "conflicts with recorded intent" : "broken"}: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}\n` +
1272
+ ` ${verdict.standing === "recorded" ? "Ask the recorder whether this is still intended, or correct the behavior. Meaning awaits named-human agreement." : afterBreakLine(verdict, context.checkBound)}${reviewSuffix}`);
1097
1273
  }
1098
1274
  const rejected = verdict.reproductionRejected
1099
1275
  ? ` A reported break did not hold up when run both ways: ${verdict.reproductionRejected}.`
@@ -1102,7 +1278,7 @@ export function verdictLine(verdict, title, context = {}) {
1102
1278
  const nextStep = verdict.nextStep?.trim()
1103
1279
  ? ` To let it run: ${sentence(verdict.nextStep, NEXT_STEP_LIMIT)}`
1104
1280
  : "";
1105
- return `could not tell: ${name}${earlier}.${notes}${rejected}${nextStep}`;
1281
+ return `could not tell: ${name}${earlier}.${notes}${rejected}${nextStep}${reviewSuffix}`;
1106
1282
  }
1107
1283
  /** The head commit of a named pull request, through the person's own `gh`. */
1108
1284
  async function pullRequestHead(root, target) {
@@ -1213,7 +1389,7 @@ export function nothingSelected(input) {
1213
1389
  if (stage?.available && stage.promises.length === 0 && input.maps === 0)
1214
1390
  return {
1215
1391
  reason: "no_promises",
1216
- message: "This repository has no agreed promise yet, so nothing was judged.",
1392
+ message: "This repository has no visible recorded or agreed promise yet, so nothing was judged.",
1217
1393
  };
1218
1394
  if (input.maps === 0 && input.anchors === 0) {
1219
1395
  if (stage?.available)
@@ -1248,11 +1424,80 @@ export function commitFirstLine(made) {
1248
1424
  }
1249
1425
  export async function runJudge(options) {
1250
1426
  const { args } = options;
1427
+ const manualArgs = { ...args };
1428
+ delete manualArgs.hook;
1429
+ delete manualArgs.userScope;
1251
1430
  const deps = options.deps ?? {};
1431
+ // The catalog path owns versioned receipt delivery, including failures to
1432
+ // read the catalog. Never downgrade it to a second, legacy reporting lane.
1433
+ const usesReviewCatalog = args.fromFile === undefined &&
1434
+ deps.reviews !== null &&
1435
+ (deps.reviews !== undefined || (deps.anchors === undefined && deps.reader === undefined));
1252
1436
  const now = deps.now ?? (() => new Date());
1253
1437
  const hook = args.hook;
1254
1438
  const json = args.json && hook === undefined;
1255
1439
  const lines = [];
1440
+ const deferredOffers = [];
1441
+ const offersForRef = async (root, base, head, config, deadline) => {
1442
+ if (!config.offers || Date.now() >= deadline)
1443
+ return false;
1444
+ const timeoutMs = Math.max(1, Math.min(1000, deadline - Date.now()));
1445
+ const changed = await runCommand("git", ["-C", root, "diff", "--name-only", base, head], {
1446
+ timeoutMs,
1447
+ });
1448
+ if (!changed.ok || changed.stdout.trim() === "")
1449
+ return false;
1450
+ if (config.offersOn === "all")
1451
+ return true;
1452
+ const messages = await runCommand("git", ["-C", root, "log", "--format=%s%x00", `${base}..${head}`], { timeoutMs: Math.max(1, Math.min(1000, deadline - Date.now())) });
1453
+ return messages.ok && isRepairChange(messages.stdout.split("\0").join("\n---\n"));
1454
+ };
1455
+ const childOutputLines = (chunks) => {
1456
+ const output = chunks.join("").trimEnd();
1457
+ return output.length === 0 ? [] : output.split("\n");
1458
+ };
1459
+ const settingsFallback = () => {
1460
+ const message = "Balladeer: app push-review settings are unavailable; using .continuity/judge.json or the defaults (3 concurrent, 240 seconds).";
1461
+ lines.push(message);
1462
+ emit({
1463
+ step: "judge",
1464
+ status: "review_settings_unavailable",
1465
+ reason: "app_settings_unavailable",
1466
+ message,
1467
+ exitCode: 0,
1468
+ });
1469
+ };
1470
+ const operationReviews = async (root) => {
1471
+ let client = deps.reviews ?? undefined;
1472
+ if (deps.reviews === undefined && deps.anchors === undefined && deps.reader === undefined) {
1473
+ const found = connectionReviewsClient({
1474
+ cwd: root,
1475
+ environment: options.environment,
1476
+ controlPlane: args.controlPlane,
1477
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1478
+ });
1479
+ if (found.ok)
1480
+ client = found.client;
1481
+ }
1482
+ if (client === undefined)
1483
+ return { deps, settings: deps.reviewSettings };
1484
+ const source = client;
1485
+ let catalog;
1486
+ const memo = { ...source, list: () => (catalog ??= source.list()) };
1487
+ const result = await memo.list();
1488
+ const settings = deps.reviewSettings ?? result.reviewSettings;
1489
+ if (settings === undefined && deps.reviewSettingsResolved !== true)
1490
+ settingsFallback();
1491
+ return {
1492
+ settings,
1493
+ deps: {
1494
+ ...deps,
1495
+ reviews: memo,
1496
+ reviewSettingsResolved: true,
1497
+ ...(settings === undefined ? {} : { reviewSettings: settings }),
1498
+ },
1499
+ };
1500
+ };
1256
1501
  const emit = (step) => {
1257
1502
  if (json)
1258
1503
  options.write(`${JSON.stringify(step)}\n`);
@@ -1290,6 +1535,7 @@ export async function runJudge(options) {
1290
1535
  let command;
1291
1536
  // Said after everything else the run has to say, before any offers.
1292
1537
  const trailing = [];
1538
+ let reportingWarning;
1293
1539
  // Set only when the check runs before a line that makes a commit before it
1294
1540
  // pushes (a checkout whose hook predates judging after the push): whatever
1295
1541
  // it says is about the code before that commit, and the answer says so
@@ -1299,11 +1545,19 @@ export async function runJudge(options) {
1299
1545
  if (commitFirst !== undefined)
1300
1546
  lines.unshift(commitFirstLine(commitFirst));
1301
1547
  commitFirst = undefined;
1302
- const offered = await settleOffers();
1548
+ if (options.deferOffers !== undefined && offerReading !== undefined)
1549
+ options.deferOffers(settleOffers());
1550
+ const offered = [
1551
+ ...(options.deferOffers === undefined ? await settleOffers() : []),
1552
+ ...(await Promise.all(deferredOffers)).flat(),
1553
+ ];
1303
1554
  lines.push(...trailing);
1555
+ if (reportingWarning)
1556
+ lines.push(reportingWarning);
1304
1557
  // Apart from the verdicts, after them, with a line between.
1305
- if (offered.length > 0)
1558
+ if (offered.length > 0) {
1306
1559
  lines.push(...(lines.length > 0 ? [""] : []), ...offered);
1560
+ }
1307
1561
  const response = hookResponse(hook ?? "git", { block, lines, event });
1308
1562
  if (!json) {
1309
1563
  if (response.stdout)
@@ -1332,14 +1586,68 @@ export async function runJudge(options) {
1332
1586
  // cannot be told for certain, nothing is judged and the answer says so.
1333
1587
  let place;
1334
1588
  // The host's session, so a could-not-tell it already heard comes back quietly.
1335
- let sessionId;
1589
+ let sessionId = args.pushSessionId;
1590
+ let hookResult;
1591
+ let toolUseId;
1592
+ let shellCwd = cwd;
1336
1593
  if (hook !== undefined) {
1337
1594
  const input = await (deps.stdin ?? (() => readStdin()))();
1338
1595
  if (hook === "git") {
1339
1596
  const refs = parsePrePushInput(input);
1340
1597
  if (refs.length === 0)
1341
1598
  return 0;
1342
- pushedHead = refs[0].localSha;
1599
+ const gitRoot = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(cwd);
1600
+ if (gitRoot === undefined)
1601
+ return 0;
1602
+ const changes = refs.filter((ref) => !/^0+$/.test(ref.localSha));
1603
+ const operationStarted = Date.now();
1604
+ const prepared = changes.length === 0
1605
+ ? { deps, settings: deps.reviewSettings }
1606
+ : await operationReviews(gitRoot);
1607
+ const gitConfig = { ...readJudgeConfig(gitRoot).config, ...prepared.settings };
1608
+ const operationDeadline = args.operationDeadline ??
1609
+ operationStarted + effectiveBudgetSeconds(gitConfig.budgetSeconds, true) * 1000;
1610
+ let blocked = false;
1611
+ let offersStarted = false;
1612
+ for (const ref of changes) {
1613
+ if (Date.now() >= operationDeadline) {
1614
+ lines.push(`Balladeer: ${ref.remoteRef}: review budget exhausted; this ref remains pending. Run \`balladeer judge --base ${ref.remoteSha} --head ${ref.localSha} --wait\`.`);
1615
+ judgeLogDirectory(gitRoot);
1616
+ appendFileSync(join(gitRoot, JUDGE_LOG_DIRECTORY, "coverage.jsonl"), `${JSON.stringify({ schemaVersion: "balladeer-review-coverage/v1", at: now().toISOString(), base: ref.remoteSha, head: ref.localSha, destinationRef: ref.remoteRef, catalogComplete: false, relevanceComplete: false, pendingRef: true, reason: "operation_budget_exhausted" })}\n`, "utf8");
1617
+ continue;
1618
+ }
1619
+ const text = [];
1620
+ const errors = [];
1621
+ const offerBase = /^0+$/.test(ref.remoteSha)
1622
+ ? await defaultBase(gitRoot, ref.localSha)
1623
+ : ref.remoteSha;
1624
+ const offerThisRef = !offersStarted &&
1625
+ offerBase !== undefined &&
1626
+ (await offersForRef(gitRoot, offerBase, ref.localSha, gitConfig, operationDeadline));
1627
+ const code = await runJudge({
1628
+ ...options,
1629
+ deps: prepared.deps,
1630
+ cwd: gitRoot,
1631
+ deferOffers: (reading) => deferredOffers.push(reading),
1632
+ args: {
1633
+ ...manualArgs,
1634
+ head: ref.localSha,
1635
+ operationDeadline,
1636
+ ...(offerThisRef ? { pushHost: hook } : {}),
1637
+ ...(/^0+$/.test(ref.remoteSha) ? {} : { base: ref.remoteSha }),
1638
+ destinationRef: ref.remoteRef,
1639
+ json: false,
1640
+ },
1641
+ write: (line) => text.push(line),
1642
+ error: (line) => errors.push(line),
1643
+ });
1644
+ offersStarted ||= offerThisRef;
1645
+ lines.push(...childOutputLines(text), ...childOutputLines(errors));
1646
+ blocked ||= code === 2;
1647
+ }
1648
+ if (changes.length === 0)
1649
+ lines.push("Balladeer: this push only deletes refs; no source change was reviewed.");
1650
+ return respond(blocked);
1343
1651
  }
1344
1652
  else {
1345
1653
  // Codex runs a user hook only once the person trusted it, so this one
@@ -1352,12 +1660,15 @@ export async function runJudge(options) {
1352
1660
  // The installer adds this command to both events; on any other event,
1353
1661
  // wired by hand, there is nothing for it to do.
1354
1662
  const called = parsed.eventName ?? "PreToolUse";
1355
- if (called !== "PreToolUse" && called !== "PostToolUse")
1663
+ if (called !== "PreToolUse" && called !== "PostToolUse" && called !== "PostToolUseFailure")
1356
1664
  return 0;
1357
1665
  event = called;
1358
1666
  command = parsed.command;
1359
1667
  if (parsed.cwd !== undefined && existsSync(parsed.cwd))
1360
1668
  cwd = parsed.cwd;
1669
+ shellCwd = cwd;
1670
+ toolUseId = parsed.toolUseId;
1671
+ hookResult = parsed.result;
1361
1672
  // Where the push leaves from first: the user-scope entry decides on
1362
1673
  // that repository, or on the session's own when the line does not say.
1363
1674
  place = pushPlace(parsed.command, cwd);
@@ -1450,14 +1761,125 @@ export async function runJudge(options) {
1450
1761
  }
1451
1762
  return refused ? 4 : 0;
1452
1763
  }
1453
- const { config, problems } = readJudgeConfig(root);
1764
+ const localConfig = readJudgeConfig(root);
1765
+ let config = { ...localConfig.config, ...deps.reviewSettings };
1766
+ const problems = localConfig.problems;
1454
1767
  const mode = args.mode ?? config.mode;
1455
1768
  for (const problem of problems)
1456
1769
  lines.push(`Balladeer judge: ${problem}.`);
1770
+ if ((hook === "claude" || hook === "codex") &&
1771
+ event === "PreToolUse" &&
1772
+ mode === "block" &&
1773
+ command !== undefined &&
1774
+ commitBeforePush(command) !== undefined) {
1775
+ lines.push(COMMIT_THEN_PUSH);
1776
+ return respond(true);
1777
+ }
1778
+ if ((hook === "claude" || hook === "codex") &&
1779
+ event === "PreToolUse" &&
1780
+ mode === "report" &&
1781
+ !args.userScope &&
1782
+ !hasJudgeEntry(root, hook, "PostToolUse")) {
1783
+ trailing.push(`Balladeer judge: this push waited for the check because this checkout's hook was installed by an earlier release. Run \`balladeer judge --install-hook ${hook}\` again so the check runs after the push instead.`);
1784
+ }
1785
+ if ((hook === "claude" || hook === "codex") &&
1786
+ command !== undefined &&
1787
+ sendsCommits(command) &&
1788
+ args.head === undefined &&
1789
+ place?.known !== false) {
1790
+ const operationOptions = {
1791
+ root,
1792
+ cwd: shellCwd,
1793
+ command,
1794
+ environment: options.environment,
1795
+ now: now(),
1796
+ ...(sessionId === undefined ? {} : { sessionId }),
1797
+ ...(toolUseId === undefined ? {} : { toolUseId }),
1798
+ };
1799
+ const operation = event === "PreToolUse"
1800
+ ? capturePushOperation(operationOptions)
1801
+ : consumePushOperation({
1802
+ ...operationOptions,
1803
+ ...(hookResult === undefined ? {} : { result: hookResult }),
1804
+ });
1805
+ if (event === "PreToolUse" &&
1806
+ mode === "report" &&
1807
+ (args.userScope || hasJudgeEntry(root, hook, "PostToolUse")))
1808
+ return 0;
1809
+ if (event !== "PreToolUse" && mode === "block")
1810
+ return 0;
1811
+ if (operation.status === "unknown") {
1812
+ lines.push(`Balladeer: push coverage is Unknown: ${operation.reason}. Run \`balladeer judge --base <before> --head <pushed>\` for each pushed branch.`);
1813
+ return respond(false);
1814
+ }
1815
+ const targets = operation.status === "reconciled" ? operation.targets : operation.operation.targets;
1816
+ const operationStarted = Date.now();
1817
+ const hasSourceChange = targets.some((target) => target.head !== null && (!("status" in target) || target.status === "pushed"));
1818
+ const prepared = hasSourceChange
1819
+ ? await operationReviews(root)
1820
+ : { deps, settings: deps.reviewSettings };
1821
+ config = { ...config, ...prepared.settings };
1822
+ const operationDeadline = args.operationDeadline ??
1823
+ operationStarted + effectiveBudgetSeconds(config.budgetSeconds, true) * 1000;
1824
+ const fanoutDeps = prepared.deps;
1825
+ let blocked = false;
1826
+ let offersStarted = false;
1827
+ for (const target of targets) {
1828
+ const status = "status" in target ? target.status : target.head === null ? "deleted" : "pushed";
1829
+ if (status !== "pushed" || target.head === null) {
1830
+ lines.push(`Balladeer: ${target.destination}: ${status}; no source change was reviewed.`);
1831
+ if (status === "unknown")
1832
+ blocked ||= mode === "block";
1833
+ continue;
1834
+ }
1835
+ // A new branch uses the bounded repository comparison base; the captured
1836
+ // destination tip is preferred whenever it is locally available.
1837
+ const base = target.base ?? (await defaultBase(root, target.head));
1838
+ if (base === undefined) {
1839
+ lines.push(`Balladeer: ${target.destination}: comparison base unavailable; coverage Unknown.`);
1840
+ blocked ||= mode === "block";
1841
+ continue;
1842
+ }
1843
+ if (Date.now() >= operationDeadline) {
1844
+ lines.push(`Balladeer: ${target.destination}: review budget exhausted; this ref remains pending. Run \`balladeer judge --base ${base} --head ${target.head} --wait\`.`);
1845
+ judgeLogDirectory(root);
1846
+ appendFileSync(join(root, JUDGE_LOG_DIRECTORY, "coverage.jsonl"), `${JSON.stringify({ schemaVersion: "balladeer-review-coverage/v1", at: now().toISOString(), base, head: target.head, destinationRef: target.destination, catalogComplete: false, relevanceComplete: false, pendingRef: true, reason: "operation_budget_exhausted" })}\n`, "utf8");
1847
+ continue;
1848
+ }
1849
+ const text = [];
1850
+ const errors = [];
1851
+ const offerThisRef = !offersStarted && (await offersForRef(root, base, target.head, config, operationDeadline));
1852
+ const code = await runJudge({
1853
+ ...options,
1854
+ deps: fanoutDeps,
1855
+ cwd: root,
1856
+ deferOffers: (reading) => deferredOffers.push(reading),
1857
+ args: {
1858
+ ...manualArgs,
1859
+ head: target.head,
1860
+ base,
1861
+ destinationRef: target.destination,
1862
+ destinationDigest: `sha256:${operation.operation.remoteDigest}`,
1863
+ operationDeadline,
1864
+ ...(offerThisRef ? { pushHost: hook } : {}),
1865
+ ...(sessionId === undefined ? {} : { pushSessionId: sessionId }),
1866
+ client: args.client ?? hook,
1867
+ json: false,
1868
+ mode,
1869
+ },
1870
+ write: (line) => text.push(line),
1871
+ error: (line) => errors.push(line),
1872
+ });
1873
+ offersStarted ||= offerThisRef;
1874
+ lines.push(`Pushed ref: ${target.destination} (${target.head.slice(0, 12)}).`, ...childOutputLines(text), ...childOutputLines(errors));
1875
+ blocked ||= code === 2;
1876
+ }
1877
+ return respond(blocked);
1878
+ }
1457
1879
  // Report mode judges after the command and block mode before it, so each
1458
1880
  // mode acts on one of the two events and lets the other go at once.
1459
1881
  if (hook === "claude" || hook === "codex") {
1460
- if (event === "PostToolUse" && mode === "block")
1882
+ if (event !== "PreToolUse" && mode === "block")
1461
1883
  return 0;
1462
1884
  if (event === "PreToolUse" && mode === "block" && command !== undefined) {
1463
1885
  if (commitBeforePush(command) !== undefined) {
@@ -1474,7 +1896,6 @@ export async function runJudge(options) {
1474
1896
  commitFirst = commitBeforePush(command);
1475
1897
  // Installed by an earlier release, with no entry after the push: judge
1476
1898
  // here as that release did, rather than not at all, and say how to move.
1477
- trailing.push(`Balladeer judge: this push waited for the check because this checkout's hook was installed by an earlier release. Run \`balladeer judge --install-hook ${hook}\` again so the check runs after the push instead.`);
1478
1899
  }
1479
1900
  }
1480
1901
  // Where the push leaves from: judged there, or not at all when that cannot be known,
@@ -1495,6 +1916,21 @@ export async function runJudge(options) {
1495
1916
  lines.push(message);
1496
1917
  return respond(false);
1497
1918
  }
1919
+ if (args.fromFile === undefined && !usesReviewCatalog) {
1920
+ try {
1921
+ const retry = await deliverReviews({
1922
+ root,
1923
+ environment: options.environment,
1924
+ controlPlane: args.controlPlane,
1925
+ payloads: [],
1926
+ ...(deps.reportReviews === undefined ? {} : { send: deps.reportReviews }),
1927
+ });
1928
+ reportingWarning = retry;
1929
+ }
1930
+ catch {
1931
+ /* Original verdict logs remain available; a later run can retry. */
1932
+ }
1933
+ }
1498
1934
  await runCommand("git", ["-C", root, "worktree", "prune"]);
1499
1935
  if (namedMerge !== undefined && args.head === undefined) {
1500
1936
  const named = [namedMerge.repo, namedMerge.selector].filter(Boolean).join(" ");
@@ -1526,30 +1962,7 @@ export async function runJudge(options) {
1526
1962
  options.error(`${message}\n`);
1527
1963
  return 4;
1528
1964
  }
1529
- // After the command, what was pushed is what is judged, so it has to have
1530
- // reached the remote: Codex calls this hook after a push that failed, too.
1531
- // A merge sends no commits of this checkout, so it has nothing to check.
1532
- if (event === "PostToolUse" &&
1533
- command !== undefined &&
1534
- sendsCommits(command) &&
1535
- namedMerge === undefined &&
1536
- args.head === undefined &&
1537
- !(await pushLanded(root, head))) {
1538
- const message = `Balladeer judge: nothing was judged, because the push did not land: ${head.slice(0, 12)} is on no remote branch.`;
1539
- emit({
1540
- step: "judge",
1541
- status: "nothing_to_judge",
1542
- reason: "push_not_landed",
1543
- message,
1544
- exitCode: 0,
1545
- });
1546
- lines.push(message);
1547
- return respond(false);
1548
- }
1549
- const base = args.base !== undefined
1550
- ? await resolveCommit(root, args.base)
1551
- : ((event === "PostToolUse" ? await baseBeforePush(root, head) : undefined) ??
1552
- (await defaultBase(root, head)));
1965
+ const base = args.base !== undefined ? await resolveCommit(root, args.base) : await defaultBase(root, head);
1553
1966
  if (base === undefined || base === head) {
1554
1967
  const message = base === undefined
1555
1968
  ? `${args.base ?? "the base"} is not a commit here, so nothing was judged.`
@@ -1613,6 +2026,39 @@ export async function runJudge(options) {
1613
2026
  if (found.ok)
1614
2027
  reader = found.reader;
1615
2028
  }
2029
+ let reviews = deps.reviews ?? undefined;
2030
+ const reviewStarted = Date.now();
2031
+ let targets;
2032
+ let catalogProblem;
2033
+ if (filePromise === undefined &&
2034
+ deps.reviews !== null &&
2035
+ (deps.reviews !== undefined || (deps.anchors === undefined && deps.reader === undefined))) {
2036
+ if (reviews === undefined) {
2037
+ const found = connectionReviewsClient(connection);
2038
+ if (found.ok)
2039
+ reviews = found.client;
2040
+ else
2041
+ catalogProblem = found.reason;
2042
+ }
2043
+ if (reviews !== undefined) {
2044
+ const catalog = await reviews.list();
2045
+ if (deps.reviewSettingsResolved !== true) {
2046
+ if (catalog.reviewSettings !== undefined)
2047
+ config = { ...config, ...catalog.reviewSettings };
2048
+ else
2049
+ settingsFallback();
2050
+ }
2051
+ if (catalog.ok) {
2052
+ targets = catalog.targets;
2053
+ reader = catalogReaders(reviews, targets).reader;
2054
+ }
2055
+ else {
2056
+ catalogProblem = catalog.reason;
2057
+ }
2058
+ }
2059
+ if (catalogProblem !== undefined)
2060
+ trailing.push(`Review coverage is incomplete: recorded promises could not be read (${catalogProblem}).`);
2061
+ }
1616
2062
  // The login is checked once, and only when something needs it: an anchoring call or a judge.
1617
2063
  const prefer = hook === "claude" || hook === "codex" ? hook : undefined;
1618
2064
  const requested = args.client ?? config.client;
@@ -1621,14 +2067,24 @@ export async function runJudge(options) {
1621
2067
  ((client, preferred) => resolveClient(client, {
1622
2068
  env: options.environment,
1623
2069
  cwd: root,
2070
+ ...(args.wait
2071
+ ? {}
2072
+ : {
2073
+ deadline: args.operationDeadline ??
2074
+ reviewStarted +
2075
+ effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000,
2076
+ }),
1624
2077
  ...(preferred === undefined ? {} : { prefer: preferred }),
1625
2078
  })))(requested, prefer));
1626
2079
  // From a hook, the host's own timeout rules and a timed-out hook lets the
1627
2080
  // push run, so the budget is capped below it; by hand, the person's number.
1628
- // It starts here, so anchoring and judging share it.
2081
+ // Catalog, anchoring, login and judging share the elapsed budget.
1629
2082
  const budgetMs = args.wait
1630
2083
  ? undefined
1631
- : effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000;
2084
+ : Math.max(1, Math.min(effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000 -
2085
+ (Date.now() - reviewStarted), args.operationDeadline === undefined
2086
+ ? Number.POSITIVE_INFINITY
2087
+ : args.operationDeadline - Date.now()));
1632
2088
  const started = Date.now();
1633
2089
  // Anchors, brought current before anything is selected: read from Balladeer,
1634
2090
  // then cut from a current map or written by a call for each agreed promise
@@ -1642,9 +2098,11 @@ export async function runJudge(options) {
1642
2098
  // exits above pay nothing for the anchoring machinery.
1643
2099
  const kit = filePromise === undefined ? await import("./anchor.js") : undefined;
1644
2100
  if (kit !== undefined) {
1645
- let client = deps.anchors ?? undefined;
2101
+ let client = targets !== undefined && reviews !== undefined
2102
+ ? catalogReaders(reviews, targets).anchors
2103
+ : (deps.anchors ?? undefined);
1646
2104
  let clientMissing;
1647
- if (deps.anchors === undefined) {
2105
+ if (client === undefined && deps.anchors === undefined) {
1648
2106
  const found = kit.connectionAnchorsClient(connection);
1649
2107
  if (found.ok)
1650
2108
  client = found.client;
@@ -1672,9 +2130,10 @@ export async function runJudge(options) {
1672
2130
  }
1673
2131
  const parsedMaps = storedMaps.flatMap((entry) => (entry.map === undefined ? [] : [entry.map]));
1674
2132
  // The user-scope entry still says nothing where there is nothing to check:
1675
- // the server says this repository has no agreed promise, or the server
2133
+ // the server says this repository has no visible promise, or the server
1676
2134
  // cannot be read and no promise here has a map.
1677
2135
  if (args.userScope &&
2136
+ catalogProblem === undefined &&
1678
2137
  stage !== undefined &&
1679
2138
  (stage.available ? stage.promises.length === 0 : parsedMaps.length === 0))
1680
2139
  return 0;
@@ -1693,16 +2152,17 @@ export async function runJudge(options) {
1693
2152
  if (!stage.currentMaps.has(promiseId))
1694
2153
  selectBy.set(promiseId, anchorFilesOf);
1695
2154
  }
1696
- if (config.offers && hook !== undefined) {
2155
+ const offerHost = hook ?? args.pushHost;
2156
+ if (config.offers && offerHost !== undefined) {
1697
2157
  // Even when no promise is at risk and no map exists: a change nobody
1698
2158
  // mapped is exactly where a rule nobody wrote down is likeliest.
1699
- offerDeadline = Date.now() + effectiveBudgetSeconds(config.budgetSeconds, true) * 1000;
2159
+ offerDeadline = Math.min(args.operationDeadline ?? Number.POSITIVE_INFINITY, Date.now() + effectiveBudgetSeconds(config.budgetSeconds, true) * 1000);
1700
2160
  offerReading = offersForPush({
1701
2161
  root,
1702
2162
  base,
1703
2163
  head,
1704
2164
  files,
1705
- host: hook,
2165
+ host: offerHost,
1706
2166
  environment: options.environment,
1707
2167
  controlPlane: args.controlPlane,
1708
2168
  client: args.client ?? config.client,
@@ -1721,7 +2181,7 @@ export async function runJudge(options) {
1721
2181
  : [];
1722
2182
  // Their meanings, read once: the scorer orders them by their words, and
1723
2183
  // their judges need them anyway.
1724
- const meanings = new Map();
2184
+ const meanings = new Map((targets ?? []).map((target) => [target.promiseId, target]));
1725
2185
  if (reader !== undefined)
1726
2186
  for (const promiseId of anchored) {
1727
2187
  const read = await reader.get(promiseId);
@@ -1732,7 +2192,7 @@ export async function runJudge(options) {
1732
2192
  // listed after those the ranking can place.
1733
2193
  const scored = [
1734
2194
  ...mapsInUse,
1735
- ...anchored.flatMap((promiseId) => {
2195
+ ...[...anchorsInUse.keys()].flatMap((promiseId) => {
1736
2196
  const anchors = anchorsInUse.get(promiseId);
1737
2197
  const meaning = meanings.get(promiseId);
1738
2198
  return anchors === undefined || kit === undefined
@@ -1756,6 +2216,46 @@ export async function runJudge(options) {
1756
2216
  ...(args.topK === undefined ? {} : { topK: args.topK }),
1757
2217
  random: deps.random ?? Math.random,
1758
2218
  });
2219
+ let relevanceComplete = targets === undefined ? filePromise !== undefined : named !== undefined;
2220
+ if (targets !== undefined && named === undefined) {
2221
+ const chosen = new Set(selected.map((entry) => entry.promiseId));
2222
+ for (const entry of targetedRiskCandidates(risk ?? [], chosen)) {
2223
+ selected.push({ promiseId: entry.promiseId, why: "risk" });
2224
+ chosen.add(entry.promiseId);
2225
+ }
2226
+ const remaining = targets.filter((target) => !chosen.has(target.promiseId));
2227
+ const backstopLogin = remaining.length === 0 ? undefined : await resolveLogin();
2228
+ const left = budgetMs === undefined ? 60_000 : Math.max(0, budgetMs - (Date.now() - started));
2229
+ if (remaining.length === 0)
2230
+ relevanceComplete = true;
2231
+ else if (backstopLogin !== undefined && left > 1000) {
2232
+ const result = await (deps.backstop ?? reviewBackstop)({
2233
+ root,
2234
+ base,
2235
+ head,
2236
+ files,
2237
+ targets: remaining,
2238
+ login: backstopLogin,
2239
+ env: options.environment,
2240
+ timeoutMs: Math.min(60_000, Math.max(1000, Math.floor(left / 4))),
2241
+ ...(deps.runAgent === undefined ? {} : { runAgent: deps.runAgent }),
2242
+ });
2243
+ relevanceComplete = result.ok;
2244
+ if (result.ok)
2245
+ for (const id of result.ids) {
2246
+ selected.push({ promiseId: id, why: "risk" });
2247
+ chosen.add(id);
2248
+ }
2249
+ else
2250
+ trailing.push(`Review coverage is incomplete: ${result.reason}.`);
2251
+ }
2252
+ else
2253
+ trailing.push("Review coverage is incomplete: the remaining promises could not receive a relevance reading.");
2254
+ }
2255
+ const logCoverage = (completed, pending) => {
2256
+ judgeLogDirectory(root);
2257
+ appendFileSync(join(root, JUDGE_LOG_DIRECTORY, "coverage.jsonl"), `${JSON.stringify({ schemaVersion: "balladeer-review-coverage/v1", at: now().toISOString(), base, head, destinationRef: args.destinationRef, catalogComplete: targets !== undefined || filePromise !== undefined, relevanceComplete, considered: targets?.map((target) => ({ id: target.promiseId, standing: target.standing, semanticDigest: target.semanticDigest, subject: target.subject })) ?? [], selected: selected.map((entry) => entry.promiseId), completed, pending })}\n`, "utf8");
2258
+ };
1759
2259
  // What anchoring did, said after the verdicts whenever it ran or was held back.
1760
2260
  const anchorRun = stage?.available ? stage.run : undefined;
1761
2261
  if (anchorRun !== undefined && kit !== undefined) {
@@ -1783,6 +2283,7 @@ export async function runJudge(options) {
1783
2283
  }
1784
2284
  }
1785
2285
  if (selected.length === 0) {
2286
+ logCoverage([], []);
1786
2287
  // Said in one line either way, so that silence is never read as safe.
1787
2288
  const nothing = nothingSelected({
1788
2289
  stage,
@@ -1802,6 +2303,7 @@ export async function runJudge(options) {
1802
2303
  }
1803
2304
  const login = await resolveLogin();
1804
2305
  if (login === undefined) {
2306
+ logCoverage([], selected.map((entry) => entry.promiseId));
1805
2307
  const message = "not judged: no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine, so Balladeer did not judge this change.";
1806
2308
  emit({ step: "judge", status: "not_judged", reason: "no_login", message, exitCode: 0 });
1807
2309
  lines.push(message);
@@ -1824,6 +2326,9 @@ export async function runJudge(options) {
1824
2326
  }
1825
2327
  if (promise === undefined && map?.meaning !== undefined)
1826
2328
  promise = promiseFromExcerpt(promiseId, map.semanticDigest, map.meaning);
2329
+ // A local proposal map cannot supply human agreement during a catalog outage.
2330
+ if (promise !== undefined && promiseId.startsWith("cand_") && promise.standing === undefined)
2331
+ promise = { ...promise, standing: "recorded" };
1827
2332
  if (promise === undefined)
1828
2333
  unreadable.push(promiseId);
1829
2334
  else
@@ -1835,6 +2340,37 @@ export async function runJudge(options) {
1835
2340
  : { anchors: kit.judgeAnchors(anchors, repository) }),
1836
2341
  });
1837
2342
  }
2343
+ const environmentDigest = sha256Digest({
2344
+ runner: JUDGE_BRIEF_REVISION,
2345
+ node: process.version,
2346
+ executable: process.execPath,
2347
+ client: login.client,
2348
+ login: login.method ?? "verified",
2349
+ path: options.environment.PATH ?? "",
2350
+ setup: [
2351
+ "package.json",
2352
+ "pnpm-lock.yaml",
2353
+ "package-lock.json",
2354
+ "requirements.txt",
2355
+ "pyproject.toml",
2356
+ "poetry.lock",
2357
+ "go.mod",
2358
+ "go.sum",
2359
+ "node_modules/.modules.yaml",
2360
+ ].map((path) => {
2361
+ try {
2362
+ const file = join(root, path);
2363
+ return {
2364
+ path,
2365
+ bytes: statSync(file).size,
2366
+ digest: sha256Digest(readFileSync(file, "utf8")),
2367
+ };
2368
+ }
2369
+ catch {
2370
+ return { path, absent: true };
2371
+ }
2372
+ }),
2373
+ });
1838
2374
  const stopHandler = () => {
1839
2375
  removeLiveWorktreesNow();
1840
2376
  process.exit(130);
@@ -1844,7 +2380,7 @@ export async function runJudge(options) {
1844
2380
  const budget = new AbortController();
1845
2381
  const timer = budgetMs === undefined
1846
2382
  ? undefined
1847
- : setTimeout(() => budget.abort(), Math.max(1000, budgetMs - (Date.now() - started)));
2383
+ : setTimeout(() => budget.abort(), Math.max(1, budgetMs - (Date.now() - started)));
1848
2384
  const verdicts = [];
1849
2385
  const stillJudging = [];
1850
2386
  const notJudged = [];
@@ -1855,11 +2391,18 @@ export async function runJudge(options) {
1855
2391
  const job = queue.shift();
1856
2392
  if (job === undefined)
1857
2393
  return;
2394
+ const cacheDigest = sha256Digest({
2395
+ environmentDigest,
2396
+ subject: job.promise.subject ?? null,
2397
+ standing: job.promise.standing ?? "unknown",
2398
+ });
1858
2399
  const logged = findLoggedVerdict(root, {
1859
2400
  promiseId: job.promise.promiseId,
1860
2401
  semanticDigest: job.promise.semanticDigest,
1861
2402
  base,
1862
2403
  head,
2404
+ environmentDigest: cacheDigest,
2405
+ ...(args.retry ? { retry: true } : {}),
1863
2406
  }, now());
1864
2407
  if (logged !== undefined) {
1865
2408
  verdicts.push({
@@ -1870,11 +2413,11 @@ export async function runJudge(options) {
1870
2413
  });
1871
2414
  continue;
1872
2415
  }
1873
- if (budget.signal.aborted) {
2416
+ if (budget.signal.aborted || (budgetMs !== undefined && Date.now() >= started + budgetMs)) {
1874
2417
  stillJudging.push(job);
1875
2418
  continue;
1876
2419
  }
1877
- const remaining = budgetMs === undefined ? JUDGE_CAP_MS : Math.max(1000, budgetMs - (Date.now() - started));
2420
+ const remaining = budgetMs === undefined ? JUDGE_CAP_MS : Math.max(1, budgetMs - (Date.now() - started));
1878
2421
  const outcome = await (deps.judge ?? judgePromise)({
1879
2422
  root,
1880
2423
  promise: job.promise,
@@ -1896,12 +2439,16 @@ export async function runJudge(options) {
1896
2439
  notJudged.push({ id: job.promise.promiseId, reason: outcome.reason });
1897
2440
  else {
1898
2441
  verdicts.push({
1899
- verdict: outcome.verdict,
2442
+ verdict: {
2443
+ ...outcome.verdict,
2444
+ environmentDigest: cacheDigest,
2445
+ ...(job.promise.standing === undefined ? {} : { standing: job.promise.standing }),
2446
+ },
1900
2447
  title: job.promise.title,
1901
2448
  reused: false,
1902
2449
  checkBound: job.promise.verifierBound,
1903
2450
  });
1904
- appendVerdicts(root, [outcome.verdict], now());
2451
+ appendVerdicts(root, [verdicts[verdicts.length - 1].verdict], now());
1905
2452
  }
1906
2453
  }
1907
2454
  };
@@ -1915,7 +2462,116 @@ export async function runJudge(options) {
1915
2462
  }
1916
2463
  const order = new Map(selected.map((entry, index) => [entry.promiseId, index]));
1917
2464
  verdicts.sort((a, b) => (order.get(a.verdict.promiseId) ?? 0) - (order.get(b.verdict.promiseId) ?? 0));
1918
- const broken = verdicts.filter((entry) => entry.verdict.verdict === "broken");
2465
+ const broken = verdicts.filter((entry) => entry.verdict.verdict === "broken" && entry.verdict.standing !== "recorded");
2466
+ const pending = [
2467
+ ...stillJudging.map((job) => job.promise.promiseId),
2468
+ ...notJudged.map((job) => job.id),
2469
+ ...unreadable,
2470
+ ...verdicts
2471
+ .filter((entry) => entry.verdict.verdict === "could_not_tell")
2472
+ .map((entry) => entry.verdict.promiseId),
2473
+ ];
2474
+ logCoverage(verdicts.map((entry) => entry.verdict.promiseId), pending);
2475
+ const shareReceipts = async () => {
2476
+ if (reviews === undefined || targets === undefined || args.destinationRef === undefined)
2477
+ return;
2478
+ const deadline = Math.min(args.operationDeadline ??
2479
+ (budgetMs === undefined ? Number.POSITIVE_INFINITY : started + budgetMs), Date.now() + 10_000);
2480
+ const abort = new AbortController();
2481
+ const expired = new Promise((done) => {
2482
+ abort.signal.addEventListener("abort", () => done({ ok: false, reason: "sharing budget exhausted" }), { once: true });
2483
+ });
2484
+ const timer = setTimeout(() => abort.abort(), Math.max(0, deadline - Date.now()));
2485
+ const remainsLocal = (id, reason) => {
2486
+ const message = `Review receipt remains local for ${id}: ${reason}.`;
2487
+ trailing.push(message);
2488
+ emit({
2489
+ step: "judge",
2490
+ status: "review_receipt_remains_local",
2491
+ promiseIds: [id],
2492
+ reason,
2493
+ message,
2494
+ exitCode: 0,
2495
+ });
2496
+ };
2497
+ try {
2498
+ for (const { verdict } of verdicts) {
2499
+ const target = targets.find((item) => item.promiseId === verdict.promiseId);
2500
+ if (target === undefined)
2501
+ continue;
2502
+ if (Date.now() >= deadline || abort.signal.aborted) {
2503
+ remainsLocal(target.promiseId, "sharing budget exhausted");
2504
+ continue;
2505
+ }
2506
+ const resolvesReportIds = [];
2507
+ if (verdict.verdict === "kept" && args.destinationDigest !== undefined) {
2508
+ for (const finding of target.openFindings ?? []) {
2509
+ if (Date.now() >= deadline || abort.signal.aborted || resolvesReportIds.length >= 100)
2510
+ break;
2511
+ if (finding.sourceSha === head ||
2512
+ finding.destinationRef !== args.destinationRef ||
2513
+ finding.destinationDigest !== args.destinationDigest)
2514
+ continue;
2515
+ const ancestor = await runCommand("git", ["-C", root, "merge-base", "--is-ancestor", finding.sourceSha, head], { timeoutMs: Math.max(1, Math.min(1000, deadline - Date.now())) });
2516
+ if (ancestor.ok)
2517
+ resolvesReportIds.push(finding.reportId);
2518
+ }
2519
+ }
2520
+ if (Date.now() >= deadline || abort.signal.aborted) {
2521
+ remainsLocal(target.promiseId, "sharing budget exhausted");
2522
+ continue;
2523
+ }
2524
+ const result = await Promise.race([
2525
+ reviews
2526
+ .record({
2527
+ schemaVersion: "balladeer-review-receipt/v1",
2528
+ subject: target.subject,
2529
+ semanticDigest: target.semanticDigest,
2530
+ baseSha: base,
2531
+ sourceSha: head,
2532
+ destinationRef: args.destinationRef,
2533
+ ...(args.destinationDigest === undefined
2534
+ ? {}
2535
+ : { destinationDigest: args.destinationDigest }),
2536
+ ...(resolvesReportIds.length === 0
2537
+ ? {}
2538
+ : { resolvesReportIds, ancestryVerified: true }),
2539
+ verdict: verdict.verdict,
2540
+ briefRevision: verdict.briefRevision,
2541
+ client: verdict.judge.client,
2542
+ ...(verdict.verdict === "broken" && verdict.reproduction?.digest !== undefined
2543
+ ? { reproductionDigest: verdict.reproduction.digest }
2544
+ : {}),
2545
+ }, { signal: abort.signal, timeoutMs: Math.max(1, deadline - Date.now()) })
2546
+ .catch(() => ({ ok: false, reason: "the receipt could not be shared" })),
2547
+ expired,
2548
+ ]);
2549
+ if (!result.ok)
2550
+ remainsLocal(target.promiseId, result.reason);
2551
+ }
2552
+ }
2553
+ finally {
2554
+ clearTimeout(timer);
2555
+ }
2556
+ };
2557
+ if (filePromise === undefined && !usesReviewCatalog) {
2558
+ try {
2559
+ const reporting = await deliverReviews({
2560
+ root,
2561
+ environment: options.environment,
2562
+ controlPlane: args.controlPlane,
2563
+ payloads: verdicts.map(({ verdict }) => reviewPayload(verdict, {
2564
+ ...(sessionId === undefined ? {} : { sessionId }),
2565
+ ...(args.pushHost === "git" ? { client: "git" } : {}),
2566
+ })),
2567
+ ...(deps.reportReviews === undefined ? {} : { send: deps.reportReviews }),
2568
+ });
2569
+ reportingWarning = reporting;
2570
+ }
2571
+ catch {
2572
+ lines.push("Review reporting could not finish. The original review remains in this machine’s local log.");
2573
+ }
2574
+ }
1919
2575
  if (json)
1920
2576
  for (const { verdict } of verdicts)
1921
2577
  options.write(`${JSON.stringify(verdict)}\n`);
@@ -1933,12 +2589,13 @@ export async function runJudge(options) {
1933
2589
  lines.push(` not judged: ${id}: ${reason}`);
1934
2590
  for (const id of unreadable)
1935
2591
  lines.push(` not judged: ${id}: its meaning could not be read here and it has no behavior map.`);
2592
+ const blockingPending = stillJudging.filter((job) => job.promise.standing !== "recorded");
1936
2593
  if (stillJudging.length > 0) {
1937
2594
  const ids = stillJudging.map((job) => job.promise.promiseId);
1938
2595
  const names = stillJudging
1939
2596
  .map((job) => `${job.promise.title} (${job.promise.promiseId})`)
1940
2597
  .join("; ");
1941
- const message = mode === "block"
2598
+ const message = mode === "block" && blockingPending.length > 0
1942
2599
  ? `Held: ${STILL_JUDGING}, then push again. Still judging when the time ran out: ${names}.`
1943
2600
  : `Still judging when the time ran out, so not judged yet: ${names}. Run \`balladeer judge --wait\` to finish them.`;
1944
2601
  lines.push(message);
@@ -1948,12 +2605,13 @@ export async function runJudge(options) {
1948
2605
  reason: "budget_spent",
1949
2606
  message,
1950
2607
  promiseIds: ids,
1951
- exitCode: mode === "block" ? 2 : 0,
2608
+ exitCode: mode === "block" && blockingPending.length > 0 ? 2 : 0,
1952
2609
  });
1953
2610
  }
1954
2611
  if (broken.length > 0 && mode === "block")
1955
2612
  lines.push("This push is stopped because a promise's break was reproduced both ways. Fix the behavior, or run the reproduction to see it, then push again.");
1956
- const block = mode === "block" && (broken.length > 0 || stillJudging.length > 0);
2613
+ const block = mode === "block" && (broken.length > 0 || blockingPending.length > 0);
2614
+ await shareReceipts();
1957
2615
  if (json)
1958
2616
  return block ? 2 : 0;
1959
2617
  return respond(block);