balladeer 1.0.18 → 1.0.20
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/README.md +18 -0
- package/dist/anchoring.js +2 -2
- package/dist/anchors-schema.d.ts +3 -1
- package/dist/anchors-schema.js +5 -3
- package/dist/checkout-form.d.ts +11 -0
- package/dist/checkout-form.js +14 -0
- package/dist/cli.js +4 -3
- package/dist/commands/guidance.js +23 -8
- package/dist/commands/judge.d.ts +50 -1
- package/dist/commands/judge.js +768 -110
- package/dist/commands/mcp.js +5 -9
- package/dist/commands/setup.js +5 -23
- package/dist/currency.d.ts +2 -0
- package/dist/currency.js +2 -1
- package/dist/guidance-hook.mjs +162 -56
- package/dist/guidance-runtime.d.ts +26 -0
- package/dist/guidance-runtime.js +71 -1
- package/dist/headless-agent.d.ts +2 -0
- package/dist/headless-agent.js +5 -1
- package/dist/judge-brief.d.ts +11 -5
- package/dist/judge-brief.js +26 -10
- package/dist/judge-evidence.d.ts +87 -0
- package/dist/judge-evidence.js +393 -0
- package/dist/judge-hook.d.ts +14 -1
- package/dist/judge-hook.js +34 -5
- package/dist/judge-review.d.ts +10 -0
- package/dist/judge-review.js +36 -0
- package/dist/judge-said.d.ts +1 -0
- package/dist/judge-said.js +3 -0
- package/dist/promise-meaning.d.ts +4 -0
- package/dist/promise-review-client.d.ts +84 -0
- package/dist/promise-review-client.js +357 -0
- package/dist/push-operation.d.ts +61 -0
- package/dist/push-operation.js +472 -0
- package/dist/read-only-review.d.ts +2 -0
- package/dist/read-only-review.js +2 -0
- package/dist/review-backstop.d.ts +20 -0
- package/dist/review-backstop.js +54 -0
- package/dist/review-reporting.d.ts +32 -0
- package/dist/review-reporting.js +319 -0
- package/dist/review-selection.d.ts +33 -0
- package/dist/review-selection.js +64 -0
- package/dist/review-settings.d.ts +6 -0
- package/dist/review-settings.js +9 -0
- package/dist/self-update.d.ts +3 -3
- package/dist/self-update.js +38 -8
- package/dist/user-scope.d.ts +1 -10
- package/dist/user-scope.js +12 -7
- package/dist/wire.d.ts +3 -3
- package/dist/wire.js +1 -1
- package/package.json +1 -1
package/dist/commands/judge.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 =
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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,
|
|
389
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
471
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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:
|
|
484
|
-
|
|
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
|
|
503
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
...
|
|
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(
|
|
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(
|
|
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:
|
|
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, [
|
|
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 ||
|
|
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);
|