balladeer 1.0.16 → 1.0.17

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.
@@ -1,15 +1,18 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { CASE_ID, PROMISE_ID, parseBehaviorMap } from "../behavior-map-schema.js";
4
4
  import { runCommand } from "../gh.js";
5
5
  import { repositoryRoot } from "../git.js";
6
6
  import { agentEnvironment, resolveClient, runHeadlessAgent, JUDGE_ACTIVE_VARIABLE, } from "../headless-agent.js";
7
7
  import { JUDGE_BRIEF_REVISION, judgePrompt } from "../judge-brief.js";
8
- import { effectiveBudgetSeconds, hookResponse, installJudgeHook, isPushCommand, parseHookInput, parsePrePushInput, } from "../judge-hook.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 { noteHookFired } from "../hook-trust.js";
10
+ import { sessionMemory } from "../judge-said.js";
9
11
  import { runOwnedProcess, tail } from "../owned-process.js";
10
12
  import { caseIds, connectionReader, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
11
13
  import { addScratchWorktree, carryFiles, newFiles, removeLiveWorktreesNow, resolveCommit, shareDependencies, } from "../scratch-worktree.js";
12
14
  import { normalizeControlPlane, StoreError } from "../store.js";
15
+ import { directness, riskOrder } from "../risk/score.js";
13
16
  import { CLI_INVOCATION, DEFAULT_CONTROL_PLANE } from "../wire.js";
14
17
  import { AGENT_TOOLS, REFUSED_TOOL_RULES, agentFailure, readBehaviorMap, readBehaviorMaps, } from "./map.js";
15
18
  import { offersForPush } from "./offers.js";
@@ -29,7 +32,15 @@ export const JUDGE_VERDICT_SCHEMA_VERSION = "balladeer-judge-verdict/v1";
29
32
  export const JUDGE_CONFIG_SCHEMA_VERSION = "balladeer-judge-config/v1";
30
33
  export const JUDGE_CONFIG_PATH = ".continuity/judge.json";
31
34
  export const JUDGE_LOG_DIRECTORY = ".continuity/judge-log";
35
+ /**
36
+ * Where a verified reproduction's files are kept, one folder per promise and
37
+ * pushed commit, inside the judge log so git ignores them like the log itself.
38
+ */
39
+ export const KEPT_REPRODUCTIONS_DIRECTORY = `${JUDGE_LOG_DIRECTORY}/reproductions`;
40
+ export const KEPT_REPRODUCTION_SCHEMA_VERSION = "balladeer-kept-reproduction/v1";
32
41
  export const STILL_JUDGING = "still judging; rerun `balladeer judge --wait`";
42
+ /** Block mode's answer to a line that commits and then pushes: the check runs before the line. */
43
+ export const COMMIT_THEN_PUSH = "Held: this command makes a commit and then pushes it, and the check runs before the command, so it cannot see a commit that has not been made yet. Commit first, then push in a separate command.";
33
44
  /** However long `--wait` allows, one judge is stopped after this. */
34
45
  const JUDGE_CAP_MS = 15 * 60_000;
35
46
  const LINKED_TEST_TIMEOUT_MS = 3 * 60_000;
@@ -43,6 +54,7 @@ export const DEFAULT_JUDGE_CONFIG = {
43
54
  sampleBelowLine: 0.1,
44
55
  alwaysJudge: [],
45
56
  client: "auto",
57
+ select: "direct",
46
58
  offers: false,
47
59
  offersOn: "fixes",
48
60
  maxOffers: 1,
@@ -104,6 +116,7 @@ export function readJudgeConfig(root) {
104
116
  sampleBelowLine: pick("sampleBelowLine", (v) => typeof v === "number" && v >= 0 && v <= 1, d.sampleBelowLine),
105
117
  alwaysJudge: pick("alwaysJudge", (v) => Array.isArray(v) && v.every((id) => typeof id === "string" && PROMISE_ID.test(id)), [...d.alwaysJudge]),
106
118
  client: pick("client", (v) => v === "auto" || v === "claude" || v === "codex", d.client),
119
+ select: pick("select", (v) => v === "direct" || v === "ranked", d.select),
107
120
  offers: pick("offers", (v) => typeof v === "boolean", d.offers),
108
121
  offersOn: pick("offersOn", (v) => v === "fixes" || v === "all", d.offersOn),
109
122
  maxOffers: pick("maxOffers", whole(1, 3), d.maxOffers),
@@ -167,6 +180,7 @@ export async function verifyReproduction(input) {
167
180
  digest,
168
181
  rejected,
169
182
  stopped: false,
183
+ files: carried,
170
184
  });
171
185
  const headCwd = insideDirectory(input.headTree, input.cwd);
172
186
  if (headCwd === undefined || !existsSync(headCwd))
@@ -177,7 +191,14 @@ export async function verifyReproduction(input) {
177
191
  ...(input.signal === undefined ? {} : { signal: input.signal }),
178
192
  });
179
193
  if (atHead.aborted)
180
- return { failsOnHead: false, passesOnBase: false, digest, rejected: null, stopped: true };
194
+ return {
195
+ failsOnHead: false,
196
+ passesOnBase: false,
197
+ digest,
198
+ rejected: null,
199
+ stopped: true,
200
+ files: carried,
201
+ };
181
202
  if (atHead.timedOut)
182
203
  return reject(false, false, `it was still running at head after ${seconds} seconds`);
183
204
  if (atHead.exitCode === null)
@@ -199,17 +220,81 @@ export async function verifyReproduction(input) {
199
220
  ...(input.signal === undefined ? {} : { signal: input.signal }),
200
221
  });
201
222
  if (atBase.aborted)
202
- return { failsOnHead: true, passesOnBase: false, digest, rejected: null, stopped: true };
223
+ return {
224
+ failsOnHead: true,
225
+ passesOnBase: false,
226
+ digest,
227
+ rejected: null,
228
+ stopped: true,
229
+ files: carried,
230
+ };
203
231
  if (atBase.timedOut)
204
232
  return reject(true, false, `it was still running at base after ${seconds} seconds`);
205
233
  if (atBase.exitCode !== 0)
206
234
  return reject(true, false, `it also fails without the change (exit ${atBase.exitCode ?? "none"}), so the failure is not this change's`);
207
- return { failsOnHead: true, passesOnBase: true, digest, rejected: null, stopped: false };
235
+ return {
236
+ failsOnHead: true,
237
+ passesOnBase: true,
238
+ digest,
239
+ rejected: null,
240
+ stopped: false,
241
+ files: carried,
242
+ };
208
243
  }
209
244
  finally {
210
245
  await tree.worktree.remove();
211
246
  }
212
247
  }
248
+ /**
249
+ * Keep a verified reproduction on this machine, so the break can become a
250
+ * lasting test instead of vanishing with the judge's checkout.
251
+ *
252
+ * The files the judge added are copied from its checkout into
253
+ * `.continuity/judge-log/reproductions/<promise id>-<first 12 of head>/files/`
254
+ * at the paths the judge wrote them, beside a `manifest.json` that says which
255
+ * promise, meaning and change they reproduce a break of, with the command,
256
+ * its cwd, the digest and each file's own fingerprint. The digest can be
257
+ * recomputed from the manifest alone. A reproduction that added no files
258
+ * keeps just the manifest. The judge log carries its own `.gitignore`, so
259
+ * nothing here is ever committed, and nothing here is sent anywhere.
260
+ *
261
+ * The folder is written beside its final name and then renamed into place,
262
+ * replacing an earlier one for the same promise and commit, so a reader never
263
+ * sees half of one. Keeping is a courtesy to the person, never a condition of
264
+ * the verdict: when it fails, the verdict stands and says nothing was kept.
265
+ */
266
+ export function keepReproduction(input) {
267
+ const keptIn = `${KEPT_REPRODUCTIONS_DIRECTORY}/${input.promiseId}-${input.head.slice(0, 12)}`;
268
+ const folder = insideDirectory(input.root, keptIn);
269
+ if (folder === undefined || !PROMISE_ID.test(input.promiseId))
270
+ return undefined;
271
+ const staging = `${folder}.partial-${process.pid}`;
272
+ try {
273
+ judgeLogDirectory(input.root);
274
+ rmSync(staging, { recursive: true, force: true });
275
+ mkdirSync(staging, { recursive: true });
276
+ const kept = carryFiles(input.headTree, join(staging, "files"), input.files);
277
+ const manifest = {
278
+ schemaVersion: KEPT_REPRODUCTION_SCHEMA_VERSION,
279
+ promiseId: input.promiseId,
280
+ semanticDigest: input.semanticDigest,
281
+ base: input.base,
282
+ head: input.head,
283
+ command: input.command,
284
+ cwd: input.cwd,
285
+ digest: input.digest,
286
+ files: kept.map((file) => ({ path: file.path, sha256: file.sha256 })),
287
+ };
288
+ writeFileSync(join(staging, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
289
+ rmSync(folder, { recursive: true, force: true });
290
+ renameSync(staging, folder);
291
+ return { keptIn, files: kept.map((file) => file.path) };
292
+ }
293
+ catch {
294
+ rmSync(staging, { recursive: true, force: true });
295
+ return undefined;
296
+ }
297
+ }
213
298
  /**
214
299
  * How this repository runs one test file, when it can be told from the files
215
300
  * alone. A runner that cannot be told is `none`, and the judge is left to find
@@ -240,6 +325,8 @@ export function linkedTestCommand(tree, file) {
240
325
  }
241
326
  return undefined;
242
327
  }
328
+ /** The longest `nextStep` kept from a judge's answer, in characters. */
329
+ export const NEXT_STEP_LIMIT = 500;
243
330
  /** The judge's final JSON, read strictly enough that a malformed answer is not a verdict. */
244
331
  export function readJudgeAnswer(json) {
245
332
  if (json === undefined)
@@ -262,11 +349,13 @@ export function readJudgeAnswer(json) {
262
349
  ...new Set(json.exercisedCases.filter((id) => typeof id === "string" && CASE_ID.test(id))),
263
350
  ]
264
351
  : [];
352
+ const nextStep = typeof json.nextStep === "string" ? json.nextStep.trim().slice(0, NEXT_STEP_LIMIT) : "";
265
353
  return {
266
354
  verdict,
267
355
  ...(reproduction === undefined ? {} : { reproduction }),
268
356
  exercisedCases,
269
357
  notes: typeof json.notes === "string" ? json.notes.slice(0, 2000) : "",
358
+ ...(nextStep === "" ? {} : { nextStep }),
270
359
  };
271
360
  }
272
361
  /**
@@ -398,6 +487,10 @@ export async function judgePromise(input) {
398
487
  notes: answer.verdict === "broken"
399
488
  ? `The judge answered broken without a command that shows it. ${answer.notes}`.trim()
400
489
  : answer.notes,
490
+ // Only the judge's own could-not-tell carries a way forward.
491
+ ...(answer.verdict === "could_not_tell" && answer.nextStep !== undefined
492
+ ? { nextStep: answer.nextStep }
493
+ : {}),
401
494
  }),
402
495
  };
403
496
  const check = await verifyReproduction({
@@ -413,12 +506,29 @@ export async function judgePromise(input) {
413
506
  });
414
507
  if (check.stopped)
415
508
  return { status: "stopped" };
509
+ // Only a break that held up both ways is worth keeping: it is the start
510
+ // of a lasting test, and a rejected one would be the start of a wrong one.
511
+ const kept = check.rejected === null && check.failsOnHead && check.passesOnBase
512
+ ? keepReproduction({
513
+ root: input.root,
514
+ headTree: headTree.path,
515
+ promiseId: input.promise.promiseId,
516
+ semanticDigest: input.promise.semanticDigest,
517
+ base: input.base,
518
+ head: input.head,
519
+ command: answer.reproduction.command,
520
+ cwd: answer.reproduction.cwd,
521
+ digest: check.digest,
522
+ files: check.files,
523
+ })
524
+ : undefined;
416
525
  const reproduction = {
417
526
  command: answer.reproduction.command,
418
527
  cwd: answer.reproduction.cwd,
419
528
  failsOnHead: check.failsOnHead,
420
529
  passesOnBase: check.passesOnBase,
421
530
  digest: check.digest,
531
+ ...(kept === undefined ? {} : { keptIn: kept.keptIn, keptFiles: kept.files }),
422
532
  };
423
533
  return {
424
534
  status: "judged",
@@ -439,6 +549,7 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
439
549
  [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]
440
550
  [--from-file <promise.json> [--map <map.json>]]
441
551
  [--hook claude|codex|git] [--install-hook [claude|codex|git]]
552
+ [--push-check on|off]
442
553
  Judge whether this change keeps the promises it could break, one promise
443
554
  at a time, on your own Claude Code or Codex login. A break counts only
444
555
  with a command that fails with the change and passes without it, and
@@ -447,14 +558,24 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
447
558
  --block exits 2 on a reproduced break, and when judges are still running
448
559
  at the time limit it holds the push rather than letting it through.
449
560
  --wait runs without the time limit. Promises come from --promises, else
450
- the top of \`balladeer risk\`, else every mapped promise, plus
451
- .continuity/judge.json's alwaysJudge and a small random sample. Verdicts
452
- are kept in .continuity/judge-log/, which git ignores. --install-hook
453
- adds the push hook to .claude/settings.json and .codex/hooks.json, or
454
- .git/hooks/pre-push with git. With "offers": true in that file, the push
455
- hook also reads the change for rules no promise records and offers at
456
- most three to whoever is pushing; it records nothing and never holds a
457
- push (see \`balladeer offers\`).
561
+ from \`balladeer risk\`: every promise whose own code or tests the
562
+ change edits, or with "select": "ranked" in .continuity/judge.json the
563
+ top of its ranking and a small random sample; else every mapped
564
+ promise; plus that file's alwaysJudge. Verdicts are kept in
565
+ .continuity/judge-log/, which git ignores. --install-hook adds the push
566
+ hook to .claude/settings.json and .codex/hooks.json, before and after
567
+ each shell command: report mode judges after a push has landed, so the
568
+ push never waits, and block mode judges before it. With git it writes
569
+ .git/hooks/pre-push, which judges before the push in both modes. With
570
+ "offers": true in .continuity/judge.json, the push hook also reads the
571
+ change for rules no promise records and offers at most three to
572
+ whoever is pushing; it records nothing and never holds a push (see
573
+ \`balladeer offers\`). Setup also writes the same pair of entries at
574
+ each host's user scope, running \`judge --hook <host> --user-scope\`,
575
+ so the check runs in every connected repository with mapped promises
576
+ and says nothing anywhere else; a checkout with its own entry keeps
577
+ the check to that entry. --push-check off turns the user-scope check
578
+ off on this laptop, and --push-check on turns it back on.
458
579
  `;
459
580
  export function parseJudgeArguments(argv) {
460
581
  const args = [...argv];
@@ -466,6 +587,8 @@ export function parseJudgeArguments(argv) {
466
587
  let mode;
467
588
  let wait = false;
468
589
  let hook;
590
+ let userScope = false;
591
+ let pushCheck;
469
592
  let installHook;
470
593
  let fromFile;
471
594
  let map;
@@ -519,6 +642,14 @@ export function parseJudgeArguments(argv) {
519
642
  wait = true;
520
643
  else if (name === "--hook")
521
644
  hook = host(name, value(name, inline));
645
+ else if (name === "--user-scope")
646
+ userScope = true;
647
+ else if (name === "--push-check") {
648
+ const requested = value(name, inline);
649
+ if (requested !== "on" && requested !== "off")
650
+ throw new StoreError("usage", "--push-check needs on or off.");
651
+ pushCheck = requested;
652
+ }
522
653
  else if (name === "--install-hook") {
523
654
  const next = inline ?? (["claude", "codex", "git"].includes(args[0] ?? "") ? args.shift() : undefined);
524
655
  installHook = next === undefined ? "agents" : host(name, next);
@@ -546,6 +677,8 @@ export function parseJudgeArguments(argv) {
546
677
  throw new StoreError("usage", "--map goes with --from-file; a mapped promise's own map is read by itself.");
547
678
  if (fromFile !== undefined && promises !== undefined)
548
679
  throw new StoreError("usage", "--from-file judges the promise in that file; leave out --promises.");
680
+ if (userScope && hook !== "claude" && hook !== "codex")
681
+ throw new StoreError("usage", "--user-scope goes with --hook claude or --hook codex.");
549
682
  const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
550
683
  return {
551
684
  ...(promises === undefined ? {} : { promises }),
@@ -556,6 +689,8 @@ export function parseJudgeArguments(argv) {
556
689
  ...(mode === undefined ? {} : { mode }),
557
690
  wait,
558
691
  ...(hook === undefined ? {} : { hook }),
692
+ ...(userScope ? { userScope: true } : {}),
693
+ ...(pushCheck === undefined ? {} : { pushCheck }),
559
694
  ...(installHook === undefined ? {} : { installHook }),
560
695
  ...(fromFile === undefined ? {} : { fromFile }),
561
696
  ...(map === undefined ? {} : { map }),
@@ -565,6 +700,51 @@ export function parseJudgeArguments(argv) {
565
700
  controlPlane: normalizeControlPlane(chosen),
566
701
  };
567
702
  }
703
+ const NO_FEATURES = {
704
+ symbol_hit: 0,
705
+ graph_forward_distance: null,
706
+ graph_reverse_distance: null,
707
+ resource_overlap: 0,
708
+ linked_test_changed: 0,
709
+ sink_reach: 0,
710
+ delta_similarity: 0,
711
+ intent_clash: 0,
712
+ prior_break: 0,
713
+ churn: 0,
714
+ };
715
+ function readFeatures(value) {
716
+ if (value === null || typeof value !== "object" || Array.isArray(value))
717
+ return undefined;
718
+ const row = value;
719
+ const count = (key) => (typeof row[key] === "number" ? row[key] : 0);
720
+ const distance = (key) => (typeof row[key] === "number" ? row[key] : null);
721
+ return {
722
+ symbol_hit: count("symbol_hit"),
723
+ graph_forward_distance: distance("graph_forward_distance"),
724
+ graph_reverse_distance: distance("graph_reverse_distance"),
725
+ resource_overlap: count("resource_overlap"),
726
+ linked_test_changed: count("linked_test_changed"),
727
+ sink_reach: count("sink_reach"),
728
+ delta_similarity: count("delta_similarity"),
729
+ intent_clash: count("intent_clash"),
730
+ prior_break: count("prior_break"),
731
+ churn: count("churn"),
732
+ };
733
+ }
734
+ /** How directly the change reaches an entry's promise, by `balladeer risk`'s own rule. */
735
+ function tierOf(entry) {
736
+ return directness(entry.features ?? NO_FEATURES);
737
+ }
738
+ /** The order `balladeer risk` lists promises in: directness, then score, then evidence, then id. */
739
+ function byEntryRisk(a, b) {
740
+ const key = (entry) => ({
741
+ promiseId: entry.promiseId,
742
+ score: entry.score,
743
+ features: entry.features ?? NO_FEATURES,
744
+ evidence: entry.evidence ?? 0,
745
+ });
746
+ return riskOrder(key(a), key(b));
747
+ }
568
748
  /** Read a risk report out of whatever `balladeer risk --json` printed. */
569
749
  export function readRiskOutput(stdout) {
570
750
  const documents = [];
@@ -592,9 +772,23 @@ export function readRiskOutput(stdout) {
592
772
  continue;
593
773
  return rows.flatMap((row) => {
594
774
  const entry = row;
595
- return typeof entry.promiseId === "string" && typeof entry.score === "number"
596
- ? [{ promiseId: entry.promiseId, score: entry.score, band: String(entry.band ?? "") }]
597
- : [];
775
+ if (typeof entry.promiseId !== "string" || typeof entry.score !== "number")
776
+ return [];
777
+ const features = readFeatures(entry.features);
778
+ const reasons = Array.isArray(entry.reasons) ? entry.reasons : [];
779
+ const evidence = reasons.reduce((sum, reason) => {
780
+ const weight = reason?.weight;
781
+ return sum + (typeof weight === "number" && Number.isFinite(weight) ? weight : 0);
782
+ }, 0);
783
+ return [
784
+ {
785
+ promiseId: entry.promiseId,
786
+ score: entry.score,
787
+ band: String(entry.band ?? ""),
788
+ ...(features === undefined ? {} : { features }),
789
+ evidence,
790
+ },
791
+ ];
598
792
  });
599
793
  }
600
794
  return undefined;
@@ -648,10 +842,20 @@ export async function changedFiles(root, base, head) {
648
842
  return diff.ok ? diff.stdout.split("\n").filter((line) => line.trim() !== "") : [];
649
843
  }
650
844
  /**
651
- * Which promises to judge: named ones, else the top of the risk report plus a
652
- * random sample below its line, else every mapped promise; then `alwaysJudge`.
653
- * The sample is what keeps the ranking honest: a break found below the line is
654
- * a miss the ranking made, counted rather than never seen.
845
+ * Which promises to judge: named ones, else from the risk report, else every
846
+ * mapped promise; then `alwaysJudge`.
847
+ *
848
+ * From the risk report, in the order `balladeer risk` lists it (how directly
849
+ * the change reaches each promise, then score, then evidence, then id; a score
850
+ * alone ties at 1 and falls to the alphabet):
851
+ *
852
+ * - "direct" takes every promise whose own code or tests the change edits
853
+ * (directness tier 3), and nothing else. The first live evaluation found
854
+ * that this selects about 1.8 promises a change and included the broken one
855
+ * in 17 of 17 cases. Only a `--top` given by hand caps it.
856
+ * - "ranked" takes the top k with a score above zero, plus a random sample
857
+ * below that line. The sample is what keeps a ranking honest: a break found
858
+ * below the line is a miss the ranking made, counted rather than never seen.
655
859
  */
656
860
  export function selectPromises(input) {
657
861
  const out = [];
@@ -666,17 +870,23 @@ export function selectPromises(input) {
666
870
  for (const id of input.named)
667
871
  add(id, "named");
668
872
  else if (input.risk !== undefined) {
669
- const k = input.topK ?? input.config.topK;
670
- const ranked = input.risk
671
- .filter((entry) => entry.band !== "unmapped" && entry.score > 0)
672
- .sort((a, b) => b.score - a.score || a.promiseId.localeCompare(b.promiseId));
673
- const top = ranked.slice(0, k);
674
- for (const entry of top)
675
- add(entry.promiseId, "risk");
676
- const below = input.risk.filter((entry) => entry.band !== "unmapped" && !top.some((row) => row.promiseId === entry.promiseId));
677
- for (const entry of below)
678
- if (input.random() < input.config.sampleBelowLine)
679
- add(entry.promiseId, "sample");
873
+ const ordered = input.risk.filter((entry) => entry.band !== "unmapped").sort(byEntryRisk);
874
+ if (input.config.select === "direct") {
875
+ const direct = ordered.filter((entry) => tierOf(entry) === 3);
876
+ const capped = input.topK === undefined ? direct : direct.slice(0, input.topK);
877
+ for (const entry of capped)
878
+ add(entry.promiseId, "risk");
879
+ }
880
+ else {
881
+ const k = input.topK ?? input.config.topK;
882
+ const top = ordered.filter((entry) => entry.score > 0).slice(0, k);
883
+ for (const entry of top)
884
+ add(entry.promiseId, "risk");
885
+ const below = ordered.filter((entry) => !top.includes(entry));
886
+ for (const entry of below)
887
+ if (input.random() < input.config.sampleBelowLine)
888
+ add(entry.promiseId, "sample");
889
+ }
680
890
  }
681
891
  else
682
892
  for (const id of input.mapped)
@@ -691,17 +901,26 @@ function logPath(root, now) {
691
901
  return join(root, JUDGE_LOG_DIRECTORY, `${day}.jsonl`);
692
902
  }
693
903
  /**
694
- * Append verdicts to today's log. The directory carries its own `.gitignore`,
695
- * so a verdict log never lands in a commit whatever the repository ignores.
904
+ * The judge log directory, made with its own `.gitignore` of `*` when it is
905
+ * new, so neither a verdict nor a kept reproduction ever lands in a commit,
906
+ * whatever the repository's own `.gitignore` says.
696
907
  */
697
- export function appendVerdicts(root, verdicts, now) {
698
- if (verdicts.length === 0)
699
- return;
908
+ function judgeLogDirectory(root) {
700
909
  const directory = join(root, JUDGE_LOG_DIRECTORY);
701
910
  mkdirSync(directory, { recursive: true });
702
911
  const ignore = join(directory, ".gitignore");
703
912
  if (!existsSync(ignore))
704
913
  writeFileSync(ignore, "*\n", "utf8");
914
+ return directory;
915
+ }
916
+ /**
917
+ * Append verdicts to today's log. The directory carries its own `.gitignore`,
918
+ * so a verdict log never lands in a commit whatever the repository ignores.
919
+ */
920
+ export function appendVerdicts(root, verdicts, now) {
921
+ if (verdicts.length === 0)
922
+ return;
923
+ judgeLogDirectory(root);
705
924
  appendFileSync(logPath(root, now), verdicts.map((verdict) => `${JSON.stringify(verdict)}\n`).join(""), "utf8");
706
925
  }
707
926
  /** A verdict already reached for exactly this promise, meaning, change and brief. */
@@ -736,23 +955,129 @@ function sentence(text, limit = 240) {
736
955
  const flat = text.replace(/\s+/g, " ").trim();
737
956
  return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
738
957
  }
739
- /** One line per verdict, the promise named by its title and id. */
740
- export function verdictLine(verdict, title, reused = false) {
958
+ /** Said after "fix the behavior": a deliberate change is the person's, and the promise its owner's. */
959
+ 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.";
960
+ /**
961
+ * What to do after a reproduced break, as one line an agent reads in order:
962
+ * where the test that shows it is kept, fix the behavior rather than the
963
+ * test, then make the break a lasting check in this repository's own tests,
964
+ * committed with the fix and linked to the promise's map, and tell the person
965
+ * in one line what was added. When the promise's own Balladeer check missed
966
+ * the break, a repair of that check is proposed to its owner as well.
967
+ */
968
+ export function afterBreakLine(verdict, checkBound) {
969
+ const id = verdict.promiseId;
970
+ // A break the person made on purpose is not the agent's to undo. Seen in a
971
+ // native run on 30 September 2026: the agent rightly asked instead of
972
+ // reverting a person's own edit, but the line should not rely on that.
973
+ const onPurpose = ` ${DELIBERATE_CHANGE}`;
974
+ const keptIn = verdict.reproduction?.keptIn;
975
+ const files = verdict.reproduction?.keptFiles ?? [];
976
+ const comment = `with ${id} in a comment at its top`;
977
+ let start;
978
+ if (keptIn !== undefined && files.length > 0) {
979
+ // The command names the files where the judge wrote them, which is not
980
+ // where they are kept, so the line says both.
981
+ const where = files.length === 1
982
+ ? `the test that shows it is kept at ${keptIn}/files/${files[0]} (the command runs it from ${files[0]})`
983
+ : `the files that show it are kept in ${keptIn}/files/ (${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}; the command runs them from those paths)`;
984
+ start =
985
+ `${where}. Fix the behavior, not the test.${onPurpose} Then make this break a lasting check: ` +
986
+ `move that test into this repository's own tests beside the code it covers, following their conventions, ${comment};`;
987
+ }
988
+ else {
989
+ start =
990
+ `${keptIn !== undefined ? "the command alone shows it, so no test file was kept. " : ""}` +
991
+ `Fix the behavior, not the test.${onPurpose} Then make this break a lasting check: ` +
992
+ `turn the command into a test in this repository's own test framework (unless it already runs one), beside the code it covers, ${comment};`;
993
+ }
994
+ const rest = ` run it and see it pass on the fix; commit it with the fix; and link it to the promise with \`balladeer map --link-test ${id} <its path>\`.`;
995
+ const repair = checkBound === true
996
+ ? " Its Balladeer check missed this break, so also propose adding this case to that check with the propose_verifier_repair tool, which goes to the promise's owner for review."
997
+ : "";
998
+ const relay = " Then tell the person in one line, in your own words rather than pasting this, what you added.";
999
+ return `Next: ${start}${rest}${repair}${relay}`;
1000
+ }
1001
+ /**
1002
+ * The verdict as it may be told now: a reproduction said to be kept whose
1003
+ * folder is no longer on this machine (a verdict read back from the log after
1004
+ * somebody cleared it) loses its kept fields, so the line never points at a
1005
+ * file that is not there.
1006
+ */
1007
+ export function stillKept(root, verdict) {
1008
+ const reproduction = verdict.reproduction;
1009
+ if (reproduction?.keptIn === undefined)
1010
+ return verdict;
1011
+ const folder = insideDirectory(root, reproduction.keptIn);
1012
+ if (folder !== undefined && existsSync(join(folder, "manifest.json")))
1013
+ return verdict;
1014
+ const rest = { ...reproduction };
1015
+ delete rest.keptIn;
1016
+ delete rest.keptFiles;
1017
+ return { ...verdict, reproduction: rest };
1018
+ }
1019
+ /**
1020
+ * One line per verdict, the promise named by its title and id. A broken one
1021
+ * carries a second, indented line saying what to do next.
1022
+ */
1023
+ export function verdictLine(verdict, title, context = {}) {
741
1024
  const name = `${title} (${verdict.promiseId})`;
742
- const earlier = reused ? " [judged earlier for this commit]" : "";
1025
+ const earlier = context.reused ? " [judged earlier for this commit]" : "";
743
1026
  if (verdict.verdict === "kept")
744
1027
  return `kept: ${name}${earlier}`;
745
1028
  if (verdict.verdict === "broken") {
746
1029
  const where = verdict.reproduction && verdict.reproduction.cwd !== "."
747
1030
  ? ` (in ${verdict.reproduction.cwd})`
748
1031
  : "";
749
- return `broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}`;
1032
+ return (`broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}\n` +
1033
+ ` ${afterBreakLine(verdict, context.checkBound)}`);
750
1034
  }
751
1035
  const rejected = verdict.reproductionRejected
752
1036
  ? ` A reported break did not hold up when run both ways: ${verdict.reproductionRejected}.`
753
1037
  : "";
754
1038
  const notes = verdict.notes ? ` ${sentence(verdict.notes)}` : "";
755
- return `could not tell: ${name}${earlier}.${notes}${rejected}`;
1039
+ const nextStep = verdict.nextStep?.trim()
1040
+ ? ` To let it run: ${sentence(verdict.nextStep, NEXT_STEP_LIMIT)}`
1041
+ : "";
1042
+ return `could not tell: ${name}${earlier}.${notes}${rejected}${nextStep}`;
1043
+ }
1044
+ /** The head commit of a named pull request, through the person's own `gh`. */
1045
+ async function pullRequestHead(root, target) {
1046
+ const result = await runCommand("gh", [
1047
+ "pr",
1048
+ "view",
1049
+ ...(target.selector === undefined ? [] : [target.selector]),
1050
+ ...(target.repo === undefined ? [] : ["--repo", target.repo]),
1051
+ "--json",
1052
+ "headRefOid",
1053
+ "--jq",
1054
+ ".headRefOid",
1055
+ ], { cwd: root, timeoutMs: 10_000 });
1056
+ const sha = result.stdout.trim();
1057
+ return result.ok && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined;
1058
+ }
1059
+ /** Said instead of verdicts when the check cannot tell which repository a push leaves from. */
1060
+ export const PUSH_REPOSITORY_UNKNOWN = "Balladeer judge: this push was not judged, because the check could not tell which repository it pushes.";
1061
+ /** Said instead of verdicts when every pull request the command opens or merges is in another repository. */
1062
+ export function pullRequestElsewhereLine(repo) {
1063
+ return `Balladeer judge: the pull request is in another repository (${repo}), not this checkout's, so nothing was judged.`;
1064
+ }
1065
+ /**
1066
+ * The repository the command's pull requests are in, when every push in it
1067
+ * is a `gh pr create` or `gh pr merge` naming, with `--repo`, `-R` or
1068
+ * `GH_REPO`, a repository that none of this checkout's remotes points at.
1069
+ * Otherwise nothing, and the change here is judged as usual.
1070
+ */
1071
+ async function pullRequestElsewhere(directory, sites) {
1072
+ if (sites.length === 0 || sites.some((site) => site.repo === undefined))
1073
+ return undefined;
1074
+ const listed = await runCommand("git", ["-C", directory, "remote", "-v"]);
1075
+ const own = listed.ok ? remoteRepositoryNames(listed.stdout) : new Set();
1076
+ const inOwn = (repo) => {
1077
+ const name = repositoryName(repo);
1078
+ return name !== undefined && own.has(name);
1079
+ };
1080
+ return sites.some((site) => inOwn(site.repo)) ? undefined : sites[0].repo;
756
1081
  }
757
1082
  async function readStdin(limit = 1024 * 1024, idleMs = 5000) {
758
1083
  if (process.stdin.isTTY)
@@ -786,7 +1111,40 @@ function describeInstall(result) {
786
1111
  return `Already current: ${result.path}. Nothing was changed.`;
787
1112
  return `Not changed: ${result.path}. ${result.reason ?? ""}`.trim();
788
1113
  }
1114
+ /** What `--push-check off` and `--push-check on` say. */
1115
+ export const PUSH_CHECK_OFF_LINE = "The push check is off on this laptop: after a push it now says nothing, in every repository. A repository's own entry from `balladeer judge --install-hook` still runs. Turn it back on with `balladeer judge --push-check on`.";
1116
+ export const PUSH_CHECK_ON_LINE = "The push check is back on for this laptop: after a push in a connected repository whose promises have behavior maps, it reports what the push may have broken, and it never holds the push.";
1117
+ /**
1118
+ * Whether the push check's user-scope entry has this push to itself, decided
1119
+ * before anything is started or fetched: the entry runs on every shell command
1120
+ * in every folder on the laptop, and most folders have nothing to judge. It
1121
+ * stands aside, saying nothing, when the laptop turned the check off, the
1122
+ * folder is in no checkout, the checkout has the push check's own project
1123
+ * entry for this host (that entry owns the check there, so a push is judged
1124
+ * once), or no promise here has a behavior map. Only then is the connection
1125
+ * looked for, which reads the checkout's remotes, and a checkout this laptop
1126
+ * has not connected is left alone too.
1127
+ */
1128
+ export function userScopeTakesThePush(input) {
1129
+ if (pushCheckOff(input.environment))
1130
+ return false;
1131
+ const root = checkoutRoot(input.cwd);
1132
+ if (root === undefined)
1133
+ return false;
1134
+ if (hasProjectJudgeHook(root, input.host))
1135
+ return false;
1136
+ const mapped = readBehaviorMaps(join(root, ".continuity/behavior-maps")).some((entry) => entry.map !== undefined);
1137
+ return mapped && input.connected(root);
1138
+ }
789
1139
  /** `balladeer judge`, from arguments already parsed. */
1140
+ /**
1141
+ * The line an answer opens with when the command makes a commit before it
1142
+ * pushes. Robert approved it on 30 September 2026: the check keeps running
1143
+ * before the push, and says plainly that it did not see the commit.
1144
+ */
1145
+ export function commitFirstLine(made) {
1146
+ return `This command runs \`${made}\` before it pushes, and Balladeer checks the code before a command runs, so this check covers the code as it was before this command, not the commit it makes. Run \`balladeer judge\` once it is pushed to check that commit.`;
1147
+ }
790
1148
  export async function runJudge(options) {
791
1149
  const { args } = options;
792
1150
  const deps = options.deps ?? {};
@@ -825,12 +1183,27 @@ export async function runJudge(options) {
825
1183
  clearTimeout(timer);
826
1184
  }
827
1185
  };
1186
+ // Which host event called: PreToolUse before the command runs, PostToolUse
1187
+ // after it. Report mode acts on the second and block mode on the first.
1188
+ let event = "PreToolUse";
1189
+ let command;
1190
+ // Said after everything else the run has to say, before any offers.
1191
+ const trailing = [];
1192
+ // Set only when the check runs before a line that makes a commit before it
1193
+ // pushes (a checkout whose hook predates judging after the push): whatever
1194
+ // it says is about the code before that commit, and the answer says so
1195
+ // first. After the push the commit exists, and nothing needs saying.
1196
+ let commitFirst;
828
1197
  const respond = async (block) => {
1198
+ if (commitFirst !== undefined)
1199
+ lines.unshift(commitFirstLine(commitFirst));
1200
+ commitFirst = undefined;
829
1201
  const offered = await settleOffers();
1202
+ lines.push(...trailing);
830
1203
  // Apart from the verdicts, after them, with a line between.
831
1204
  if (offered.length > 0)
832
1205
  lines.push(...(lines.length > 0 ? [""] : []), ...offered);
833
- const response = hookResponse(hook ?? "git", { block, lines });
1206
+ const response = hookResponse(hook ?? "git", { block, lines, event });
834
1207
  if (!json) {
835
1208
  if (response.stdout)
836
1209
  options.write(response.stdout);
@@ -839,12 +1212,26 @@ export async function runJudge(options) {
839
1212
  }
840
1213
  return response.exitCode;
841
1214
  };
1215
+ if (args.pushCheck !== undefined) {
1216
+ setPushCheck(options.environment, args.pushCheck === "on");
1217
+ options.write(`${args.pushCheck === "on" ? PUSH_CHECK_ON_LINE : PUSH_CHECK_OFF_LINE}\n`);
1218
+ return 0;
1219
+ }
842
1220
  // A judge's own shell commands run with this set, and a push hook inside a
843
1221
  // judge would otherwise start judging all over again.
844
1222
  if (hook !== undefined && options.environment[JUDGE_ACTIVE_VARIABLE] === "1")
845
1223
  return 0;
846
1224
  let cwd = options.cwd;
847
1225
  let pushedHead;
1226
+ // A merge that names a pull request is about that pull request's change,
1227
+ // which need not be the one in this checkout.
1228
+ let namedMerge;
1229
+ // Where the command pushes from, which is not the session's folder when it
1230
+ // moves first (`cd ../other && git push`, `git -C ../other push`). When that
1231
+ // cannot be told for certain, nothing is judged and the answer says so.
1232
+ let place;
1233
+ // The host's session, so a could-not-tell it already heard comes back quietly.
1234
+ let sessionId;
848
1235
  if (hook !== undefined) {
849
1236
  const input = await (deps.stdin ?? (() => readStdin()))();
850
1237
  if (hook === "git") {
@@ -854,17 +1241,62 @@ export async function runJudge(options) {
854
1241
  pushedHead = refs[0].localSha;
855
1242
  }
856
1243
  else {
1244
+ // Codex runs a user hook only once the person trusted it, so this one
1245
+ // running at all, push or not, says the push check is trusted there.
1246
+ if (args.userScope && hook === "codex")
1247
+ noteHookFired(options.environment, "codex", now().getTime(), "push-check");
857
1248
  const parsed = parseHookInput(input);
858
1249
  if (parsed?.command === undefined || !isPushCommand(parsed.command))
859
1250
  return 0;
1251
+ // The installer adds this command to both events; on any other event,
1252
+ // wired by hand, there is nothing for it to do.
1253
+ const called = parsed.eventName ?? "PreToolUse";
1254
+ if (called !== "PreToolUse" && called !== "PostToolUse")
1255
+ return 0;
1256
+ event = called;
1257
+ command = parsed.command;
860
1258
  if (parsed.cwd !== undefined && existsSync(parsed.cwd))
861
1259
  cwd = parsed.cwd;
1260
+ // Where the push leaves from first: the user-scope entry decides on
1261
+ // that repository, or on the session's own when the line does not say.
1262
+ place = pushPlace(parsed.command, cwd);
1263
+ if (place.known && existsSync(place.directory))
1264
+ cwd = place.directory;
1265
+ else
1266
+ place = { known: false };
1267
+ sessionId = parsed.sessionId;
1268
+ if (args.userScope &&
1269
+ !userScopeTakesThePush({
1270
+ cwd,
1271
+ host: hook,
1272
+ environment: options.environment,
1273
+ connected: deps.connected ??
1274
+ ((root) => connectionReader({
1275
+ environment: options.environment,
1276
+ controlPlane: args.controlPlane,
1277
+ cwd: root,
1278
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1279
+ }).ok),
1280
+ }))
1281
+ return 0;
1282
+ namedMerge = mergeTargets(parsed.command).find((target) => target.selector !== undefined || target.repo !== undefined);
862
1283
  }
863
1284
  }
1285
+ const pushedFromUnknown = () => {
1286
+ emit({
1287
+ step: "judge",
1288
+ status: "nothing_to_judge",
1289
+ reason: "repository_unknown",
1290
+ message: PUSH_REPOSITORY_UNKNOWN,
1291
+ exitCode: 0,
1292
+ });
1293
+ lines.push(PUSH_REPOSITORY_UNKNOWN);
1294
+ return respond(false);
1295
+ };
864
1296
  const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(cwd);
865
1297
  if (root === undefined || !existsSync(root)) {
866
1298
  if (hook !== undefined)
867
- return 0;
1299
+ return place?.known === false ? pushedFromUnknown() : 0;
868
1300
  const message = "This is not a git repository, so there is no change to judge.";
869
1301
  emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
870
1302
  if (!json)
@@ -882,7 +1314,7 @@ export async function runJudge(options) {
882
1314
  options.write(`${describeInstall(result)}\n`);
883
1315
  }
884
1316
  if (!json) {
885
- options.write('It runs `balladeer judge` before a push or a pull request and says nothing for any other command. Report mode is the default; set "mode": "block" in .continuity/judge.json to stop a push on a reproduced break.\n');
1317
+ options.write('It runs `balladeer judge` when a command pushes or opens or merges a pull request, and says nothing for any other command. In report mode, the default, it judges once the push has landed, so the push never waits for it. Set "mode": "block" in .continuity/judge.json to judge before the push instead and stop it on a reproduced break. A session that is already open picks up the hook once it is restarted.\n');
886
1318
  if (targets.includes("codex"))
887
1319
  options.write("Codex runs a project hook once you trust it: open /hooks in Codex and review it.\n");
888
1320
  }
@@ -892,7 +1324,68 @@ export async function runJudge(options) {
892
1324
  const mode = args.mode ?? config.mode;
893
1325
  for (const problem of problems)
894
1326
  lines.push(`Balladeer judge: ${problem}.`);
1327
+ // Report mode judges after the command and block mode before it, so each
1328
+ // mode acts on one of the two events and lets the other go at once.
1329
+ if (hook === "claude" || hook === "codex") {
1330
+ if (event === "PostToolUse" && mode === "block")
1331
+ return 0;
1332
+ if (event === "PreToolUse" && mode === "block" && command !== undefined) {
1333
+ if (commitBeforePush(command) !== undefined) {
1334
+ lines.push(COMMIT_THEN_PUSH);
1335
+ return respond(true);
1336
+ }
1337
+ }
1338
+ if (event === "PreToolUse" && mode === "report") {
1339
+ // Setup writes the user-scope pair together, so its entry after the
1340
+ // push is there to judge; an earlier project install may have only this one.
1341
+ if (args.userScope || hasJudgeEntry(root, hook, "PostToolUse"))
1342
+ return 0;
1343
+ if (command !== undefined)
1344
+ commitFirst = commitBeforePush(command);
1345
+ // Installed by an earlier release, with no entry after the push: judge
1346
+ // here as that release did, rather than not at all, and say how to move.
1347
+ 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.`);
1348
+ }
1349
+ }
1350
+ // Where the push leaves from: judged there, or not at all when that cannot be known,
1351
+ // and a pull request in another repository is not this checkout's to judge. After
1352
+ // the event gate, so each line is said once, on the event that acts.
1353
+ if (place?.known === false)
1354
+ return pushedFromUnknown();
1355
+ const elsewhere = place?.known ? await pullRequestElsewhere(cwd, place.sites) : undefined;
1356
+ if (elsewhere !== undefined) {
1357
+ const message = pullRequestElsewhereLine(elsewhere);
1358
+ emit({
1359
+ step: "judge",
1360
+ status: "nothing_to_judge",
1361
+ reason: "pull_request_elsewhere",
1362
+ message,
1363
+ exitCode: 0,
1364
+ });
1365
+ lines.push(message);
1366
+ return respond(false);
1367
+ }
895
1368
  await runCommand("git", ["-C", root, "worktree", "prune"]);
1369
+ if (namedMerge !== undefined && args.head === undefined) {
1370
+ const named = [namedMerge.repo, namedMerge.selector].filter(Boolean).join(" ");
1371
+ const target = await (deps.pullRequestHead ?? pullRequestHead)(root, namedMerge);
1372
+ const local = target === undefined ? undefined : await resolveCommit(root, target);
1373
+ if (local === undefined) {
1374
+ const message = target === undefined
1375
+ ? `Balladeer judge: could not read which commit pull request ${named} would merge, so it was not judged.`
1376
+ : `Balladeer judge: pull request ${named} merges ${target.slice(0, 12)}, which is not in this checkout, so it was not judged.`;
1377
+ emit({
1378
+ step: "judge",
1379
+ status: "nothing_to_judge",
1380
+ reason: "merge_elsewhere",
1381
+ message,
1382
+ exitCode: 0,
1383
+ });
1384
+ lines.push(message);
1385
+ return respond(false);
1386
+ }
1387
+ pushedHead = local;
1388
+ }
896
1389
  const head = await resolveCommit(root, args.head ?? pushedHead ?? "HEAD");
897
1390
  if (head === undefined) {
898
1391
  const message = `${args.head ?? "HEAD"} is not a commit here, so there is nothing to judge.`;
@@ -903,7 +1396,30 @@ export async function runJudge(options) {
903
1396
  options.error(`${message}\n`);
904
1397
  return 4;
905
1398
  }
906
- const base = args.base !== undefined ? await resolveCommit(root, args.base) : await defaultBase(root, head);
1399
+ // After the command, what was pushed is what is judged, so it has to have
1400
+ // reached the remote: Codex calls this hook after a push that failed, too.
1401
+ // A merge sends no commits of this checkout, so it has nothing to check.
1402
+ if (event === "PostToolUse" &&
1403
+ command !== undefined &&
1404
+ sendsCommits(command) &&
1405
+ namedMerge === undefined &&
1406
+ args.head === undefined &&
1407
+ !(await pushLanded(root, head))) {
1408
+ const message = `Balladeer judge: nothing was judged, because the push did not land: ${head.slice(0, 12)} is on no remote branch.`;
1409
+ emit({
1410
+ step: "judge",
1411
+ status: "nothing_to_judge",
1412
+ reason: "push_not_landed",
1413
+ message,
1414
+ exitCode: 0,
1415
+ });
1416
+ lines.push(message);
1417
+ return respond(false);
1418
+ }
1419
+ const base = args.base !== undefined
1420
+ ? await resolveCommit(root, args.base)
1421
+ : ((event === "PostToolUse" ? await baseBeforePush(root, head) : undefined) ??
1422
+ (await defaultBase(root, head)));
907
1423
  if (base === undefined || base === head) {
908
1424
  const message = base === undefined
909
1425
  ? `${args.base ?? "the base"} is not a commit here, so nothing was judged.`
@@ -993,8 +1509,18 @@ export async function runJudge(options) {
993
1509
  random: deps.random ?? Math.random,
994
1510
  });
995
1511
  if (selected.length === 0) {
996
- const message = "No promise here has a behavior map yet, so nothing was judged. Build them with `balladeer map --all`.";
997
- emit({ step: "judge", status: "nothing_to_judge", reason: "no_maps", message, exitCode: 0 });
1512
+ // Said in one line either way, so that silence is never read as safe.
1513
+ const noMaps = mappedIds.length === 0;
1514
+ const message = noMaps
1515
+ ? "No promise here has a behavior map yet, so nothing was judged. Build them with `balladeer map --all`."
1516
+ : `This ${hook === undefined ? "change" : "push"} touches no promise's mapped code, so nothing was judged.`;
1517
+ emit({
1518
+ step: "judge",
1519
+ status: "nothing_to_judge",
1520
+ reason: noMaps ? "no_maps" : "no_promise_touched",
1521
+ message,
1522
+ exitCode: 0,
1523
+ });
998
1524
  lines.push(message);
999
1525
  return respond(false);
1000
1526
  }
@@ -1072,7 +1598,12 @@ export async function runJudge(options) {
1072
1598
  head,
1073
1599
  }, now());
1074
1600
  if (logged !== undefined) {
1075
- verdicts.push({ verdict: logged, title: job.promise.title, reused: true });
1601
+ verdicts.push({
1602
+ verdict: logged,
1603
+ title: job.promise.title,
1604
+ reused: true,
1605
+ checkBound: job.promise.verifierBound,
1606
+ });
1076
1607
  continue;
1077
1608
  }
1078
1609
  if (budget.signal.aborted) {
@@ -1099,7 +1630,12 @@ export async function runJudge(options) {
1099
1630
  else if (outcome.status === "not_judged")
1100
1631
  notJudged.push({ id: job.promise.promiseId, reason: outcome.reason });
1101
1632
  else {
1102
- verdicts.push({ verdict: outcome.verdict, title: job.promise.title, reused: false });
1633
+ verdicts.push({
1634
+ verdict: outcome.verdict,
1635
+ title: job.promise.title,
1636
+ reused: false,
1637
+ checkBound: job.promise.verifierBound,
1638
+ });
1103
1639
  appendVerdicts(root, [outcome.verdict], now());
1104
1640
  }
1105
1641
  }
@@ -1119,8 +1655,15 @@ export async function runJudge(options) {
1119
1655
  for (const { verdict } of verdicts)
1120
1656
  options.write(`${JSON.stringify(verdict)}\n`);
1121
1657
  lines.push(`Balladeer judged this change (${base.slice(0, 12)}..${head.slice(0, 12)}, ${files.length} files) against ${verdicts.length} of ${selected.length} promises it could affect, in ${mode} mode:`);
1122
- for (const { verdict, title, reused } of verdicts)
1123
- lines.push(` ${verdictLine(verdict, title, reused)}`);
1658
+ // A could-not-tell this session already heard in full comes back as one quiet line.
1659
+ const told = sessionMemory(join(root, JUDGE_LOG_DIRECTORY), sessionId, now());
1660
+ for (const { verdict, title, reused, checkBound } of verdicts)
1661
+ lines.push(` ${told?.repeat(verdict, title) ??
1662
+ verdictLine(stillKept(root, verdict), title, {
1663
+ reused,
1664
+ ...(checkBound === undefined ? {} : { checkBound }),
1665
+ })}`);
1666
+ told?.save();
1124
1667
  for (const { id, reason } of notJudged)
1125
1668
  lines.push(` not judged: ${id}: ${reason}`);
1126
1669
  for (const id of unreadable)