balladeer 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/anchor-budget.d.ts +40 -0
  2. package/dist/anchor-budget.js +110 -0
  3. package/dist/anchoring.d.ts +241 -0
  4. package/dist/anchoring.js +766 -0
  5. package/dist/anchors-client.d.ts +62 -0
  6. package/dist/anchors-client.js +197 -0
  7. package/dist/anchors-schema.d.ts +85 -0
  8. package/dist/anchors-schema.js +246 -0
  9. package/dist/cli.d.ts +1 -1
  10. package/dist/cli.js +24 -7
  11. package/dist/commands/anchor-usage.d.ts +6 -0
  12. package/dist/commands/anchor-usage.js +19 -0
  13. package/dist/commands/anchor.d.ts +127 -0
  14. package/dist/commands/anchor.js +303 -0
  15. package/dist/commands/guidance.d.ts +2 -1
  16. package/dist/commands/guidance.js +55 -0
  17. package/dist/commands/judge.d.ts +219 -12
  18. package/dist/commands/judge.js +911 -103
  19. package/dist/commands/map.d.ts +46 -2
  20. package/dist/commands/map.js +244 -5
  21. package/dist/commands/mcp.js +2 -2
  22. package/dist/guidance-hook.mjs +282 -95
  23. package/dist/guidance.d.ts +7 -0
  24. package/dist/guidance.js +10 -1
  25. package/dist/headless-agent.d.ts +19 -1
  26. package/dist/headless-agent.js +22 -5
  27. package/dist/hook-trust.d.ts +41 -9
  28. package/dist/hook-trust.js +98 -16
  29. package/dist/judge-brief.d.ts +33 -3
  30. package/dist/judge-brief.js +39 -2
  31. package/dist/judge-hook.d.ts +188 -14
  32. package/dist/judge-hook.js +919 -61
  33. package/dist/judge-said.d.ts +52 -0
  34. package/dist/judge-said.js +181 -0
  35. package/dist/promise-meaning.d.ts +25 -0
  36. package/dist/promise-meaning.js +24 -7
  37. package/dist/relay.d.ts +71 -0
  38. package/dist/relay.js +193 -0
  39. package/dist/remove-earlier.js +4 -2
  40. package/dist/risk/git.d.ts +3 -1
  41. package/dist/risk/git.js +3 -3
  42. package/dist/risk/graph-cache.d.ts +83 -0
  43. package/dist/risk/graph-cache.js +291 -0
  44. package/dist/risk/import-graph.d.ts +43 -0
  45. package/dist/risk/import-graph.js +88 -34
  46. package/dist/risk/index.d.ts +1 -1
  47. package/dist/risk/index.js +1 -1
  48. package/dist/risk/pipeline.d.ts +13 -0
  49. package/dist/risk/pipeline.js +15 -4
  50. package/dist/risk/score.d.ts +10 -0
  51. package/dist/risk/score.js +11 -2
  52. package/dist/scratch-worktree.d.ts +5 -1
  53. package/dist/scratch-worktree.js +9 -2
  54. package/dist/user-scope.d.ts +74 -3
  55. package/dist/user-scope.js +270 -9
  56. package/dist/wire.d.ts +19 -3
  57. package/dist/wire.js +2 -2
  58. package/package.json +7 -2
@@ -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
- import { CASE_ID, PROMISE_ID, parseBehaviorMap } from "../behavior-map-schema.js";
3
+ import { BEHAVIOR_MAP_DIRECTORY, 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
- import { caseIds, connectionReader, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
12
+ import { caseIds, connectionReader, meaningExcerpt, 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,9 +32,19 @@ 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;
46
+ /** With `--wait`, anchoring may still start calls for this long. */
47
+ const ANCHORING_WAIT_MS = 10 * 60_000;
35
48
  const LINKED_TEST_TIMEOUT_MS = 3 * 60_000;
36
49
  const REPRODUCTION_TIMEOUT_MS = 5 * 60_000;
37
50
  export const DEFAULT_JUDGE_CONFIG = {
@@ -43,6 +56,7 @@ export const DEFAULT_JUDGE_CONFIG = {
43
56
  sampleBelowLine: 0.1,
44
57
  alwaysJudge: [],
45
58
  client: "auto",
59
+ select: "direct",
46
60
  offers: false,
47
61
  offersOn: "fixes",
48
62
  maxOffers: 1,
@@ -104,6 +118,7 @@ export function readJudgeConfig(root) {
104
118
  sampleBelowLine: pick("sampleBelowLine", (v) => typeof v === "number" && v >= 0 && v <= 1, d.sampleBelowLine),
105
119
  alwaysJudge: pick("alwaysJudge", (v) => Array.isArray(v) && v.every((id) => typeof id === "string" && PROMISE_ID.test(id)), [...d.alwaysJudge]),
106
120
  client: pick("client", (v) => v === "auto" || v === "claude" || v === "codex", d.client),
121
+ select: pick("select", (v) => v === "direct" || v === "ranked", d.select),
107
122
  offers: pick("offers", (v) => typeof v === "boolean", d.offers),
108
123
  offersOn: pick("offersOn", (v) => v === "fixes" || v === "all", d.offersOn),
109
124
  maxOffers: pick("maxOffers", whole(1, 3), d.maxOffers),
@@ -167,6 +182,7 @@ export async function verifyReproduction(input) {
167
182
  digest,
168
183
  rejected,
169
184
  stopped: false,
185
+ files: carried,
170
186
  });
171
187
  const headCwd = insideDirectory(input.headTree, input.cwd);
172
188
  if (headCwd === undefined || !existsSync(headCwd))
@@ -177,7 +193,14 @@ export async function verifyReproduction(input) {
177
193
  ...(input.signal === undefined ? {} : { signal: input.signal }),
178
194
  });
179
195
  if (atHead.aborted)
180
- return { failsOnHead: false, passesOnBase: false, digest, rejected: null, stopped: true };
196
+ return {
197
+ failsOnHead: false,
198
+ passesOnBase: false,
199
+ digest,
200
+ rejected: null,
201
+ stopped: true,
202
+ files: carried,
203
+ };
181
204
  if (atHead.timedOut)
182
205
  return reject(false, false, `it was still running at head after ${seconds} seconds`);
183
206
  if (atHead.exitCode === null)
@@ -199,17 +222,81 @@ export async function verifyReproduction(input) {
199
222
  ...(input.signal === undefined ? {} : { signal: input.signal }),
200
223
  });
201
224
  if (atBase.aborted)
202
- return { failsOnHead: true, passesOnBase: false, digest, rejected: null, stopped: true };
225
+ return {
226
+ failsOnHead: true,
227
+ passesOnBase: false,
228
+ digest,
229
+ rejected: null,
230
+ stopped: true,
231
+ files: carried,
232
+ };
203
233
  if (atBase.timedOut)
204
234
  return reject(true, false, `it was still running at base after ${seconds} seconds`);
205
235
  if (atBase.exitCode !== 0)
206
236
  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 };
237
+ return {
238
+ failsOnHead: true,
239
+ passesOnBase: true,
240
+ digest,
241
+ rejected: null,
242
+ stopped: false,
243
+ files: carried,
244
+ };
208
245
  }
209
246
  finally {
210
247
  await tree.worktree.remove();
211
248
  }
212
249
  }
250
+ /**
251
+ * Keep a verified reproduction on this machine, so the break can become a
252
+ * lasting test instead of vanishing with the judge's checkout.
253
+ *
254
+ * The files the judge added are copied from its checkout into
255
+ * `.continuity/judge-log/reproductions/<promise id>-<first 12 of head>/files/`
256
+ * at the paths the judge wrote them, beside a `manifest.json` that says which
257
+ * promise, meaning and change they reproduce a break of, with the command,
258
+ * its cwd, the digest and each file's own fingerprint. The digest can be
259
+ * recomputed from the manifest alone. A reproduction that added no files
260
+ * keeps just the manifest. The judge log carries its own `.gitignore`, so
261
+ * nothing here is ever committed, and nothing here is sent anywhere.
262
+ *
263
+ * The folder is written beside its final name and then renamed into place,
264
+ * replacing an earlier one for the same promise and commit, so a reader never
265
+ * sees half of one. Keeping is a courtesy to the person, never a condition of
266
+ * the verdict: when it fails, the verdict stands and says nothing was kept.
267
+ */
268
+ export function keepReproduction(input) {
269
+ const keptIn = `${KEPT_REPRODUCTIONS_DIRECTORY}/${input.promiseId}-${input.head.slice(0, 12)}`;
270
+ const folder = insideDirectory(input.root, keptIn);
271
+ if (folder === undefined || !PROMISE_ID.test(input.promiseId))
272
+ return undefined;
273
+ const staging = `${folder}.partial-${process.pid}`;
274
+ try {
275
+ judgeLogDirectory(input.root);
276
+ rmSync(staging, { recursive: true, force: true });
277
+ mkdirSync(staging, { recursive: true });
278
+ const kept = carryFiles(input.headTree, join(staging, "files"), input.files);
279
+ const manifest = {
280
+ schemaVersion: KEPT_REPRODUCTION_SCHEMA_VERSION,
281
+ promiseId: input.promiseId,
282
+ semanticDigest: input.semanticDigest,
283
+ base: input.base,
284
+ head: input.head,
285
+ command: input.command,
286
+ cwd: input.cwd,
287
+ digest: input.digest,
288
+ files: kept.map((file) => ({ path: file.path, sha256: file.sha256 })),
289
+ };
290
+ writeFileSync(join(staging, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
291
+ rmSync(folder, { recursive: true, force: true });
292
+ renameSync(staging, folder);
293
+ return { keptIn, files: kept.map((file) => file.path) };
294
+ }
295
+ catch {
296
+ rmSync(staging, { recursive: true, force: true });
297
+ return undefined;
298
+ }
299
+ }
213
300
  /**
214
301
  * How this repository runs one test file, when it can be told from the files
215
302
  * alone. A runner that cannot be told is `none`, and the judge is left to find
@@ -240,6 +327,8 @@ export function linkedTestCommand(tree, file) {
240
327
  }
241
328
  return undefined;
242
329
  }
330
+ /** The longest `nextStep` kept from a judge's answer, in characters. */
331
+ export const NEXT_STEP_LIMIT = 500;
243
332
  /** The judge's final JSON, read strictly enough that a malformed answer is not a verdict. */
244
333
  export function readJudgeAnswer(json) {
245
334
  if (json === undefined)
@@ -262,11 +351,13 @@ export function readJudgeAnswer(json) {
262
351
  ...new Set(json.exercisedCases.filter((id) => typeof id === "string" && CASE_ID.test(id))),
263
352
  ]
264
353
  : [];
354
+ const nextStep = typeof json.nextStep === "string" ? json.nextStep.trim().slice(0, NEXT_STEP_LIMIT) : "";
265
355
  return {
266
356
  verdict,
267
357
  ...(reproduction === undefined ? {} : { reproduction }),
268
358
  exercisedCases,
269
359
  notes: typeof json.notes === "string" ? json.notes.slice(0, 2000) : "",
360
+ ...(nextStep === "" ? {} : { nextStep }),
270
361
  };
271
362
  }
272
363
  /**
@@ -286,7 +377,7 @@ export async function judgePromise(input) {
286
377
  await shareDependencies(input.root, headTree.path);
287
378
  let linkedTestRun = { file: "", outcome: "none" };
288
379
  const linkedTestRuns = [];
289
- const firstTest = input.map?.linkedTests[0];
380
+ const firstTest = input.map !== undefined ? input.map.linkedTests[0] : input.anchors?.linkedTests[0];
290
381
  if (firstTest !== undefined) {
291
382
  const command = linkedTestCommand(headTree.path, firstTest.file);
292
383
  if (command === undefined)
@@ -320,6 +411,9 @@ export async function judgePromise(input) {
320
411
  prompt: judgePrompt({
321
412
  promise: input.promise,
322
413
  ...(input.map === undefined ? {} : { map: input.map }),
414
+ ...(input.map !== undefined || input.anchors === undefined
415
+ ? {}
416
+ : { anchors: input.anchors }),
323
417
  changedFiles: input.changedFiles,
324
418
  base: input.base,
325
419
  head: input.head,
@@ -360,6 +454,7 @@ export async function judgePromise(input) {
360
454
  judge,
361
455
  briefRevision: JUDGE_BRIEF_REVISION,
362
456
  mapUsed: input.map !== undefined,
457
+ anchorsUsed: input.map === undefined && input.anchors !== undefined,
363
458
  linkedTestRun,
364
459
  ...fields,
365
460
  });
@@ -398,6 +493,10 @@ export async function judgePromise(input) {
398
493
  notes: answer.verdict === "broken"
399
494
  ? `The judge answered broken without a command that shows it. ${answer.notes}`.trim()
400
495
  : answer.notes,
496
+ // Only the judge's own could-not-tell carries a way forward.
497
+ ...(answer.verdict === "could_not_tell" && answer.nextStep !== undefined
498
+ ? { nextStep: answer.nextStep }
499
+ : {}),
401
500
  }),
402
501
  };
403
502
  const check = await verifyReproduction({
@@ -413,12 +512,29 @@ export async function judgePromise(input) {
413
512
  });
414
513
  if (check.stopped)
415
514
  return { status: "stopped" };
515
+ // Only a break that held up both ways is worth keeping: it is the start
516
+ // of a lasting test, and a rejected one would be the start of a wrong one.
517
+ const kept = check.rejected === null && check.failsOnHead && check.passesOnBase
518
+ ? keepReproduction({
519
+ root: input.root,
520
+ headTree: headTree.path,
521
+ promiseId: input.promise.promiseId,
522
+ semanticDigest: input.promise.semanticDigest,
523
+ base: input.base,
524
+ head: input.head,
525
+ command: answer.reproduction.command,
526
+ cwd: answer.reproduction.cwd,
527
+ digest: check.digest,
528
+ files: check.files,
529
+ })
530
+ : undefined;
416
531
  const reproduction = {
417
532
  command: answer.reproduction.command,
418
533
  cwd: answer.reproduction.cwd,
419
534
  failsOnHead: check.failsOnHead,
420
535
  passesOnBase: check.passesOnBase,
421
536
  digest: check.digest,
537
+ ...(kept === undefined ? {} : { keptIn: kept.keptIn, keptFiles: kept.files }),
422
538
  };
423
539
  return {
424
540
  status: "judged",
@@ -439,6 +555,7 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
439
555
  [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]
440
556
  [--from-file <promise.json> [--map <map.json>]]
441
557
  [--hook claude|codex|git] [--install-hook [claude|codex|git]]
558
+ [--push-check on|off]
442
559
  Judge whether this change keeps the promises it could break, one promise
443
560
  at a time, on your own Claude Code or Codex login. A break counts only
444
561
  with a command that fails with the change and passes without it, and
@@ -447,14 +564,27 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
447
564
  --block exits 2 on a reproduced break, and when judges are still running
448
565
  at the time limit it holds the push rather than letting it through.
449
566
  --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\`).
567
+ every agreed promise whose anchored code the change edits, and every
568
+ promise with a behavior map whose own code or tests it edits (with
569
+ "select": "ranked" in .continuity/judge.json, the top of the maps'
570
+ ranking and a small random sample); plus that file's alwaysJudge. A
571
+ promise with no anchors yet is anchored first, by one small call on
572
+ your own login (see \`balladeer anchor\`), at most 20 a day on this
573
+ laptop, and a line says what that cost. Verdicts are kept in
574
+ .continuity/judge-log/, which git ignores. --install-hook adds the push
575
+ hook to .claude/settings.json and .codex/hooks.json, before and after
576
+ each shell command: report mode judges after a push has landed, so the
577
+ push never waits, and block mode judges before it. With git it writes
578
+ .git/hooks/pre-push, which judges before the push in both modes. With
579
+ "offers": true in .continuity/judge.json, the push hook also reads the
580
+ change for rules no promise records and offers at most three to
581
+ whoever is pushing; it records nothing and never holds a push (see
582
+ \`balladeer offers\`). Setup also writes the same pair of entries at
583
+ each host's user scope, running \`judge --hook <host> --user-scope\`,
584
+ so the check runs in every connected repository with agreed promises
585
+ and says nothing anywhere else; a checkout with its own entry keeps
586
+ the check to that entry. --push-check off turns the user-scope check
587
+ off on this laptop, and --push-check on turns it back on.
458
588
  `;
459
589
  export function parseJudgeArguments(argv) {
460
590
  const args = [...argv];
@@ -466,6 +596,8 @@ export function parseJudgeArguments(argv) {
466
596
  let mode;
467
597
  let wait = false;
468
598
  let hook;
599
+ let userScope = false;
600
+ let pushCheck;
469
601
  let installHook;
470
602
  let fromFile;
471
603
  let map;
@@ -519,6 +651,14 @@ export function parseJudgeArguments(argv) {
519
651
  wait = true;
520
652
  else if (name === "--hook")
521
653
  hook = host(name, value(name, inline));
654
+ else if (name === "--user-scope")
655
+ userScope = true;
656
+ else if (name === "--push-check") {
657
+ const requested = value(name, inline);
658
+ if (requested !== "on" && requested !== "off")
659
+ throw new StoreError("usage", "--push-check needs on or off.");
660
+ pushCheck = requested;
661
+ }
522
662
  else if (name === "--install-hook") {
523
663
  const next = inline ?? (["claude", "codex", "git"].includes(args[0] ?? "") ? args.shift() : undefined);
524
664
  installHook = next === undefined ? "agents" : host(name, next);
@@ -546,6 +686,8 @@ export function parseJudgeArguments(argv) {
546
686
  throw new StoreError("usage", "--map goes with --from-file; a mapped promise's own map is read by itself.");
547
687
  if (fromFile !== undefined && promises !== undefined)
548
688
  throw new StoreError("usage", "--from-file judges the promise in that file; leave out --promises.");
689
+ if (userScope && hook !== "claude" && hook !== "codex")
690
+ throw new StoreError("usage", "--user-scope goes with --hook claude or --hook codex.");
549
691
  const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
550
692
  return {
551
693
  ...(promises === undefined ? {} : { promises }),
@@ -556,6 +698,8 @@ export function parseJudgeArguments(argv) {
556
698
  ...(mode === undefined ? {} : { mode }),
557
699
  wait,
558
700
  ...(hook === undefined ? {} : { hook }),
701
+ ...(userScope ? { userScope: true } : {}),
702
+ ...(pushCheck === undefined ? {} : { pushCheck }),
559
703
  ...(installHook === undefined ? {} : { installHook }),
560
704
  ...(fromFile === undefined ? {} : { fromFile }),
561
705
  ...(map === undefined ? {} : { map }),
@@ -565,6 +709,51 @@ export function parseJudgeArguments(argv) {
565
709
  controlPlane: normalizeControlPlane(chosen),
566
710
  };
567
711
  }
712
+ const NO_FEATURES = {
713
+ symbol_hit: 0,
714
+ graph_forward_distance: null,
715
+ graph_reverse_distance: null,
716
+ resource_overlap: 0,
717
+ linked_test_changed: 0,
718
+ sink_reach: 0,
719
+ delta_similarity: 0,
720
+ intent_clash: 0,
721
+ prior_break: 0,
722
+ churn: 0,
723
+ };
724
+ function readFeatures(value) {
725
+ if (value === null || typeof value !== "object" || Array.isArray(value))
726
+ return undefined;
727
+ const row = value;
728
+ const count = (key) => (typeof row[key] === "number" ? row[key] : 0);
729
+ const distance = (key) => (typeof row[key] === "number" ? row[key] : null);
730
+ return {
731
+ symbol_hit: count("symbol_hit"),
732
+ graph_forward_distance: distance("graph_forward_distance"),
733
+ graph_reverse_distance: distance("graph_reverse_distance"),
734
+ resource_overlap: count("resource_overlap"),
735
+ linked_test_changed: count("linked_test_changed"),
736
+ sink_reach: count("sink_reach"),
737
+ delta_similarity: count("delta_similarity"),
738
+ intent_clash: count("intent_clash"),
739
+ prior_break: count("prior_break"),
740
+ churn: count("churn"),
741
+ };
742
+ }
743
+ /** How directly the change reaches an entry's promise, by `balladeer risk`'s own rule. */
744
+ function tierOf(entry) {
745
+ return directness(entry.features ?? NO_FEATURES);
746
+ }
747
+ /** The order `balladeer risk` lists promises in: directness, then score, then evidence, then id. */
748
+ function byEntryRisk(a, b) {
749
+ const key = (entry) => ({
750
+ promiseId: entry.promiseId,
751
+ score: entry.score,
752
+ features: entry.features ?? NO_FEATURES,
753
+ evidence: entry.evidence ?? 0,
754
+ });
755
+ return riskOrder(key(a), key(b));
756
+ }
568
757
  /** Read a risk report out of whatever `balladeer risk --json` printed. */
569
758
  export function readRiskOutput(stdout) {
570
759
  const documents = [];
@@ -592,27 +781,64 @@ export function readRiskOutput(stdout) {
592
781
  continue;
593
782
  return rows.flatMap((row) => {
594
783
  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
- : [];
784
+ if (typeof entry.promiseId !== "string" || typeof entry.score !== "number")
785
+ return [];
786
+ const features = readFeatures(entry.features);
787
+ const reasons = Array.isArray(entry.reasons) ? entry.reasons : [];
788
+ const evidence = reasons.reduce((sum, reason) => {
789
+ const weight = reason?.weight;
790
+ return sum + (typeof weight === "number" && Number.isFinite(weight) ? weight : 0);
791
+ }, 0);
792
+ return [
793
+ {
794
+ promiseId: entry.promiseId,
795
+ score: entry.score,
796
+ band: String(entry.band ?? ""),
797
+ ...(features === undefined ? {} : { features }),
798
+ evidence,
799
+ },
800
+ ];
598
801
  });
599
802
  }
600
803
  return undefined;
601
804
  }
602
- /** `balladeer risk --json`, run as a child of this same command, when this build has it. */
603
- async function riskFromCommand(root, base, env) {
604
- const entry = process.argv[1];
605
- if (entry === undefined || !/(^|[/\\])(cli\.(m?js|ts)|balladeer)$/.test(entry))
805
+ /**
806
+ * `balladeer risk`'s report for this change, worked out in this process over
807
+ * the maps in use and the anchors of the promises they select (in a map's
808
+ * shape), with the graph the anchoring step already built. Undefined when the
809
+ * change cannot be read, and the selection falls back as it says.
810
+ */
811
+ async function riskInProcess(root, base, head, maps, graph) {
812
+ if (maps.length === 0)
813
+ return [];
814
+ try {
815
+ const { assessChange } = await import("../risk/pipeline.js");
816
+ const { report } = await assessChange({
817
+ root,
818
+ base,
819
+ head,
820
+ maps,
821
+ ...(graph === undefined ? {} : { graph: graph.graph }),
822
+ });
823
+ return report.promises.map((entry) => ({
824
+ promiseId: entry.promiseId,
825
+ score: entry.score,
826
+ band: entry.band,
827
+ features: entry.features,
828
+ evidence: entry.reasons.reduce((sum, reason) => sum + reason.weight, 0),
829
+ }));
830
+ }
831
+ catch {
606
832
  return undefined;
607
- const run = await runOwnedProcess({
608
- command: process.execPath,
609
- args: [...process.execArgv, entry, "risk", "--json", "--base", base],
610
- cwd: root,
611
- env: { ...env, [JUDGE_ACTIVE_VARIABLE]: "1" },
612
- timeoutMs: 60_000,
613
- maxOutputBytes: 4 * 1024 * 1024,
614
- });
615
- return run.exitCode === 0 ? readRiskOutput(run.stdout) : undefined;
833
+ }
834
+ }
835
+ /**
836
+ * Every path the change touches, a renamed file under both its names, so a
837
+ * promise anchored to a file the change moves or deletes counts as edited.
838
+ */
839
+ async function changedPathsBothSides(root, base, head) {
840
+ const diff = await runCommand("git", ["-C", root, "diff", "--name-only", "--no-renames", base, head], { timeoutMs: 60_000 });
841
+ return diff.ok ? diff.stdout.split("\n").filter((line) => line.trim() !== "") : [];
616
842
  }
617
843
  /**
618
844
  * The commit a change is measured from: the fork point with the remote default
@@ -648,10 +874,30 @@ export async function changedFiles(root, base, head) {
648
874
  return diff.ok ? diff.stdout.split("\n").filter((line) => line.trim() !== "") : [];
649
875
  }
650
876
  /**
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.
877
+ * Which promises to judge: named ones; else the promises the change selects
878
+ * by their anchors and by their maps; then `alwaysJudge`.
879
+ *
880
+ * By anchors (`anchored`, chosen before this from the files the change edits):
881
+ * every promise one of whose anchor files (an entry point's or a symbol's
882
+ * file) the change edits, with no widening by import distance. The
883
+ * measurement of 1 October 2026 found that widening multiplied the promises
884
+ * selected and caught nothing more.
885
+ *
886
+ * By maps, from the risk report, in the order `balladeer risk` lists it (how
887
+ * directly the change reaches each promise, then score, then evidence, then
888
+ * id; a score alone ties at 1 and falls to the alphabet):
889
+ *
890
+ * - "direct" takes every mapped promise whose own code or tests the change
891
+ * edits (directness tier 3), and nothing else. Only a `--top` given by hand
892
+ * caps it, and it caps anchored and mapped promises together.
893
+ * - "ranked" takes the top k mapped promises with a score above zero, plus a
894
+ * random sample below that line. The sample is what keeps a ranking honest:
895
+ * a break found below the line is a miss the ranking made, counted rather
896
+ * than never seen. Anchored promises are taken too, before the sample.
897
+ *
898
+ * Without a risk report, every mapped promise. Anchored and mapped promises
899
+ * are listed together in the report's order, and one the report does not
900
+ * list comes after those it does.
655
901
  */
656
902
  export function selectPromises(input) {
657
903
  const out = [];
@@ -662,25 +908,52 @@ export function selectPromises(input) {
662
908
  seen.add(promiseId);
663
909
  out.push({ promiseId, why });
664
910
  };
911
+ const anchored = new Set(input.anchored ?? []);
665
912
  if (input.named !== undefined)
666
913
  for (const id of input.named)
667
914
  add(id, "named");
668
915
  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");
916
+ const ordered = input.risk.filter((entry) => entry.band !== "unmapped").sort(byEntryRisk);
917
+ const position = new Map(ordered.map((entry, index) => [entry.promiseId, index]));
918
+ const inOrder = (list) => list
919
+ .map((entry, index) => ({ entry, index }))
920
+ .sort((a, b) => (position.get(a.entry.promiseId) ?? Number.MAX_SAFE_INTEGER) -
921
+ (position.get(b.entry.promiseId) ?? Number.MAX_SAFE_INTEGER) || a.index - b.index)
922
+ .map(({ entry }) => entry);
923
+ const byAnchors = [...anchored].map((promiseId) => ({ promiseId, why: "anchored" }));
924
+ const mapped = ordered.filter((entry) => !anchored.has(entry.promiseId));
925
+ if (input.config.select === "direct") {
926
+ const direct = inOrder([
927
+ ...mapped
928
+ .filter((entry) => tierOf(entry) === 3)
929
+ .map((entry) => ({ promiseId: entry.promiseId, why: "risk" })),
930
+ ...byAnchors,
931
+ ]);
932
+ const capped = input.topK === undefined ? direct : direct.slice(0, input.topK);
933
+ for (const entry of capped)
934
+ add(entry.promiseId, entry.why);
935
+ }
936
+ else {
937
+ const k = input.topK ?? input.config.topK;
938
+ const top = mapped.filter((entry) => entry.score > 0).slice(0, k);
939
+ const chosen = inOrder([
940
+ ...top.map((entry) => ({ promiseId: entry.promiseId, why: "risk" })),
941
+ ...byAnchors,
942
+ ]);
943
+ for (const entry of chosen)
944
+ add(entry.promiseId, entry.why);
945
+ const below = mapped.filter((entry) => !top.includes(entry));
946
+ for (const entry of below)
947
+ if (input.random() < input.config.sampleBelowLine)
948
+ add(entry.promiseId, "sample");
949
+ }
680
950
  }
681
- else
951
+ else {
682
952
  for (const id of input.mapped)
683
953
  add(id, "mapped");
954
+ for (const id of anchored)
955
+ add(id, "anchored");
956
+ }
684
957
  for (const id of input.config.alwaysJudge)
685
958
  add(id, "always");
686
959
  return out;
@@ -691,17 +964,26 @@ function logPath(root, now) {
691
964
  return join(root, JUDGE_LOG_DIRECTORY, `${day}.jsonl`);
692
965
  }
693
966
  /**
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.
967
+ * The judge log directory, made with its own `.gitignore` of `*` when it is
968
+ * new, so neither a verdict nor a kept reproduction ever lands in a commit,
969
+ * whatever the repository's own `.gitignore` says.
696
970
  */
697
- export function appendVerdicts(root, verdicts, now) {
698
- if (verdicts.length === 0)
699
- return;
971
+ function judgeLogDirectory(root) {
700
972
  const directory = join(root, JUDGE_LOG_DIRECTORY);
701
973
  mkdirSync(directory, { recursive: true });
702
974
  const ignore = join(directory, ".gitignore");
703
975
  if (!existsSync(ignore))
704
976
  writeFileSync(ignore, "*\n", "utf8");
977
+ return directory;
978
+ }
979
+ /**
980
+ * Append verdicts to today's log. The directory carries its own `.gitignore`,
981
+ * so a verdict log never lands in a commit whatever the repository ignores.
982
+ */
983
+ export function appendVerdicts(root, verdicts, now) {
984
+ if (verdicts.length === 0)
985
+ return;
986
+ judgeLogDirectory(root);
705
987
  appendFileSync(logPath(root, now), verdicts.map((verdict) => `${JSON.stringify(verdict)}\n`).join(""), "utf8");
706
988
  }
707
989
  /** A verdict already reached for exactly this promise, meaning, change and brief. */
@@ -736,23 +1018,129 @@ function sentence(text, limit = 240) {
736
1018
  const flat = text.replace(/\s+/g, " ").trim();
737
1019
  return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
738
1020
  }
739
- /** One line per verdict, the promise named by its title and id. */
740
- export function verdictLine(verdict, title, reused = false) {
1021
+ /** Said after "fix the behavior": a deliberate change is the person's, and the promise its owner's. */
1022
+ 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.";
1023
+ /**
1024
+ * What to do after a reproduced break, as one line an agent reads in order:
1025
+ * where the test that shows it is kept, fix the behavior rather than the
1026
+ * test, then make the break a lasting check in this repository's own tests,
1027
+ * committed with the fix and linked to the promise's map, and tell the person
1028
+ * in one line what was added. When the promise's own Balladeer check missed
1029
+ * the break, a repair of that check is proposed to its owner as well.
1030
+ */
1031
+ export function afterBreakLine(verdict, checkBound) {
1032
+ const id = verdict.promiseId;
1033
+ // A break the person made on purpose is not the agent's to undo. Seen in a
1034
+ // native run on 30 September 2026: the agent rightly asked instead of
1035
+ // reverting a person's own edit, but the line should not rely on that.
1036
+ const onPurpose = ` ${DELIBERATE_CHANGE}`;
1037
+ const keptIn = verdict.reproduction?.keptIn;
1038
+ const files = verdict.reproduction?.keptFiles ?? [];
1039
+ const comment = `with ${id} in a comment at its top`;
1040
+ let start;
1041
+ if (keptIn !== undefined && files.length > 0) {
1042
+ // The command names the files where the judge wrote them, which is not
1043
+ // where they are kept, so the line says both.
1044
+ const where = files.length === 1
1045
+ ? `the test that shows it is kept at ${keptIn}/files/${files[0]} (the command runs it from ${files[0]})`
1046
+ : `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)`;
1047
+ start =
1048
+ `${where}. Fix the behavior, not the test.${onPurpose} Then make this break a lasting check: ` +
1049
+ `move that test into this repository's own tests beside the code it covers, following their conventions, ${comment};`;
1050
+ }
1051
+ else {
1052
+ start =
1053
+ `${keptIn !== undefined ? "the command alone shows it, so no test file was kept. " : ""}` +
1054
+ `Fix the behavior, not the test.${onPurpose} Then make this break a lasting check: ` +
1055
+ `turn the command into a test in this repository's own test framework (unless it already runs one), beside the code it covers, ${comment};`;
1056
+ }
1057
+ 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>\`.`;
1058
+ const repair = checkBound === true
1059
+ ? " 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."
1060
+ : "";
1061
+ const relay = " Then tell the person in one line, in your own words rather than pasting this, what you added.";
1062
+ return `Next: ${start}${rest}${repair}${relay}`;
1063
+ }
1064
+ /**
1065
+ * The verdict as it may be told now: a reproduction said to be kept whose
1066
+ * folder is no longer on this machine (a verdict read back from the log after
1067
+ * somebody cleared it) loses its kept fields, so the line never points at a
1068
+ * file that is not there.
1069
+ */
1070
+ export function stillKept(root, verdict) {
1071
+ const reproduction = verdict.reproduction;
1072
+ if (reproduction?.keptIn === undefined)
1073
+ return verdict;
1074
+ const folder = insideDirectory(root, reproduction.keptIn);
1075
+ if (folder !== undefined && existsSync(join(folder, "manifest.json")))
1076
+ return verdict;
1077
+ const rest = { ...reproduction };
1078
+ delete rest.keptIn;
1079
+ delete rest.keptFiles;
1080
+ return { ...verdict, reproduction: rest };
1081
+ }
1082
+ /**
1083
+ * One line per verdict, the promise named by its title and id. A broken one
1084
+ * carries a second, indented line saying what to do next.
1085
+ */
1086
+ export function verdictLine(verdict, title, context = {}) {
741
1087
  const name = `${title} (${verdict.promiseId})`;
742
- const earlier = reused ? " [judged earlier for this commit]" : "";
1088
+ const earlier = context.reused ? " [judged earlier for this commit]" : "";
743
1089
  if (verdict.verdict === "kept")
744
1090
  return `kept: ${name}${earlier}`;
745
1091
  if (verdict.verdict === "broken") {
746
1092
  const where = verdict.reproduction && verdict.reproduction.cwd !== "."
747
1093
  ? ` (in ${verdict.reproduction.cwd})`
748
1094
  : "";
749
- return `broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}`;
1095
+ return (`broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}\n` +
1096
+ ` ${afterBreakLine(verdict, context.checkBound)}`);
750
1097
  }
751
1098
  const rejected = verdict.reproductionRejected
752
1099
  ? ` A reported break did not hold up when run both ways: ${verdict.reproductionRejected}.`
753
1100
  : "";
754
1101
  const notes = verdict.notes ? ` ${sentence(verdict.notes)}` : "";
755
- return `could not tell: ${name}${earlier}.${notes}${rejected}`;
1102
+ const nextStep = verdict.nextStep?.trim()
1103
+ ? ` To let it run: ${sentence(verdict.nextStep, NEXT_STEP_LIMIT)}`
1104
+ : "";
1105
+ return `could not tell: ${name}${earlier}.${notes}${rejected}${nextStep}`;
1106
+ }
1107
+ /** The head commit of a named pull request, through the person's own `gh`. */
1108
+ async function pullRequestHead(root, target) {
1109
+ const result = await runCommand("gh", [
1110
+ "pr",
1111
+ "view",
1112
+ ...(target.selector === undefined ? [] : [target.selector]),
1113
+ ...(target.repo === undefined ? [] : ["--repo", target.repo]),
1114
+ "--json",
1115
+ "headRefOid",
1116
+ "--jq",
1117
+ ".headRefOid",
1118
+ ], { cwd: root, timeoutMs: 10_000 });
1119
+ const sha = result.stdout.trim();
1120
+ return result.ok && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined;
1121
+ }
1122
+ /** Said instead of verdicts when the check cannot tell which repository a push leaves from. */
1123
+ export const PUSH_REPOSITORY_UNKNOWN = "Balladeer judge: this push was not judged, because the check could not tell which repository it pushes.";
1124
+ /** Said instead of verdicts when every pull request the command opens or merges is in another repository. */
1125
+ export function pullRequestElsewhereLine(repo) {
1126
+ return `Balladeer judge: the pull request is in another repository (${repo}), not this checkout's, so nothing was judged.`;
1127
+ }
1128
+ /**
1129
+ * The repository the command's pull requests are in, when every push in it
1130
+ * is a `gh pr create` or `gh pr merge` naming, with `--repo`, `-R` or
1131
+ * `GH_REPO`, a repository that none of this checkout's remotes points at.
1132
+ * Otherwise nothing, and the change here is judged as usual.
1133
+ */
1134
+ async function pullRequestElsewhere(directory, sites) {
1135
+ if (sites.length === 0 || sites.some((site) => site.repo === undefined))
1136
+ return undefined;
1137
+ const listed = await runCommand("git", ["-C", directory, "remote", "-v"]);
1138
+ const own = listed.ok ? remoteRepositoryNames(listed.stdout) : new Set();
1139
+ const inOwn = (repo) => {
1140
+ const name = repositoryName(repo);
1141
+ return name !== undefined && own.has(name);
1142
+ };
1143
+ return sites.some((site) => inOwn(site.repo)) ? undefined : sites[0].repo;
756
1144
  }
757
1145
  async function readStdin(limit = 1024 * 1024, idleMs = 5000) {
758
1146
  if (process.stdin.isTTY)
@@ -786,7 +1174,78 @@ function describeInstall(result) {
786
1174
  return `Already current: ${result.path}. Nothing was changed.`;
787
1175
  return `Not changed: ${result.path}. ${result.reason ?? ""}`.trim();
788
1176
  }
1177
+ /** What `--push-check off` and `--push-check on` say. */
1178
+ 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`.";
1179
+ export const PUSH_CHECK_ON_LINE = "The push check is back on for this laptop: after a push in a connected repository with agreed promises, it reports what the push may have broken, and it never holds the push.";
1180
+ /**
1181
+ * Whether the push check's user-scope entry has this push to itself, decided
1182
+ * before anything is started or fetched: the entry runs on every shell command
1183
+ * in every folder on the laptop, and most folders have nothing to judge. It
1184
+ * stands aside, saying nothing, when the laptop turned the check off, the
1185
+ * folder is in no checkout, or the checkout has the push check's own project
1186
+ * entry for this host (that entry owns the check there, so a push is judged
1187
+ * once). Only then is the connection looked for, which reads the checkout's
1188
+ * remotes, and a checkout this laptop has not connected is left alone too.
1189
+ *
1190
+ * A connected checkout is not left alone for having no behavior map: since
1191
+ * promise anchors (approved 30 September 2026), its agreed promises are
1192
+ * anchored and checked without one. Whether it has any agreed promise is the
1193
+ * server's to say, after the push, and with none (and no map) the entry still
1194
+ * says nothing.
1195
+ */
1196
+ export function userScopeTakesThePush(input) {
1197
+ if (pushCheckOff(input.environment))
1198
+ return false;
1199
+ const root = checkoutRoot(input.cwd);
1200
+ if (root === undefined)
1201
+ return false;
1202
+ if (hasProjectJudgeHook(root, input.host))
1203
+ return false;
1204
+ return input.connected(root);
1205
+ }
1206
+ /**
1207
+ * The one line said when nothing was selected, and its code: no agreed
1208
+ * promise here; nothing anchored or mapped (with why, when the anchors could
1209
+ * not be read); or nothing the change touches.
1210
+ */
1211
+ export function nothingSelected(input) {
1212
+ const { stage } = input;
1213
+ if (stage?.available && stage.promises.length === 0 && input.maps === 0)
1214
+ return {
1215
+ reason: "no_promises",
1216
+ message: "This repository has no agreed promise yet, so nothing was judged.",
1217
+ };
1218
+ if (input.maps === 0 && input.anchors === 0) {
1219
+ if (stage?.available)
1220
+ return {
1221
+ reason: "nothing_anchored",
1222
+ message: "No promise here is anchored or mapped yet, so nothing was judged.",
1223
+ };
1224
+ if (stage === undefined || stage.unsupported)
1225
+ return {
1226
+ reason: "no_maps",
1227
+ message: "No promise here has a behavior map yet, so nothing was judged. Build them with `balladeer map --all`.",
1228
+ };
1229
+ return {
1230
+ reason: "anchors_unavailable",
1231
+ message: `Balladeer could not read this repository's promise anchors (${stage.reason}), and no promise here has a behavior map, so nothing was judged.`,
1232
+ };
1233
+ }
1234
+ const code = input.anchors === 0 ? "mapped" : input.maps === 0 ? "anchored" : "anchored or mapped";
1235
+ return {
1236
+ reason: "no_promise_touched",
1237
+ message: `This ${input.push ? "push" : "change"} touches no promise's ${code} code, so nothing was judged.`,
1238
+ };
1239
+ }
789
1240
  /** `balladeer judge`, from arguments already parsed. */
1241
+ /**
1242
+ * The line an answer opens with when the command makes a commit before it
1243
+ * pushes. Robert approved it on 30 September 2026: the check keeps running
1244
+ * before the push, and says plainly that it did not see the commit.
1245
+ */
1246
+ export function commitFirstLine(made) {
1247
+ 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.`;
1248
+ }
790
1249
  export async function runJudge(options) {
791
1250
  const { args } = options;
792
1251
  const deps = options.deps ?? {};
@@ -825,12 +1284,27 @@ export async function runJudge(options) {
825
1284
  clearTimeout(timer);
826
1285
  }
827
1286
  };
1287
+ // Which host event called: PreToolUse before the command runs, PostToolUse
1288
+ // after it. Report mode acts on the second and block mode on the first.
1289
+ let event = "PreToolUse";
1290
+ let command;
1291
+ // Said after everything else the run has to say, before any offers.
1292
+ const trailing = [];
1293
+ // Set only when the check runs before a line that makes a commit before it
1294
+ // pushes (a checkout whose hook predates judging after the push): whatever
1295
+ // it says is about the code before that commit, and the answer says so
1296
+ // first. After the push the commit exists, and nothing needs saying.
1297
+ let commitFirst;
828
1298
  const respond = async (block) => {
1299
+ if (commitFirst !== undefined)
1300
+ lines.unshift(commitFirstLine(commitFirst));
1301
+ commitFirst = undefined;
829
1302
  const offered = await settleOffers();
1303
+ lines.push(...trailing);
830
1304
  // Apart from the verdicts, after them, with a line between.
831
1305
  if (offered.length > 0)
832
1306
  lines.push(...(lines.length > 0 ? [""] : []), ...offered);
833
- const response = hookResponse(hook ?? "git", { block, lines });
1307
+ const response = hookResponse(hook ?? "git", { block, lines, event });
834
1308
  if (!json) {
835
1309
  if (response.stdout)
836
1310
  options.write(response.stdout);
@@ -839,12 +1313,26 @@ export async function runJudge(options) {
839
1313
  }
840
1314
  return response.exitCode;
841
1315
  };
1316
+ if (args.pushCheck !== undefined) {
1317
+ setPushCheck(options.environment, args.pushCheck === "on");
1318
+ options.write(`${args.pushCheck === "on" ? PUSH_CHECK_ON_LINE : PUSH_CHECK_OFF_LINE}\n`);
1319
+ return 0;
1320
+ }
842
1321
  // A judge's own shell commands run with this set, and a push hook inside a
843
1322
  // judge would otherwise start judging all over again.
844
1323
  if (hook !== undefined && options.environment[JUDGE_ACTIVE_VARIABLE] === "1")
845
1324
  return 0;
846
1325
  let cwd = options.cwd;
847
1326
  let pushedHead;
1327
+ // A merge that names a pull request is about that pull request's change,
1328
+ // which need not be the one in this checkout.
1329
+ let namedMerge;
1330
+ // Where the command pushes from, which is not the session's folder when it
1331
+ // moves first (`cd ../other && git push`, `git -C ../other push`). When that
1332
+ // cannot be told for certain, nothing is judged and the answer says so.
1333
+ let place;
1334
+ // The host's session, so a could-not-tell it already heard comes back quietly.
1335
+ let sessionId;
848
1336
  if (hook !== undefined) {
849
1337
  const input = await (deps.stdin ?? (() => readStdin()))();
850
1338
  if (hook === "git") {
@@ -854,17 +1342,91 @@ export async function runJudge(options) {
854
1342
  pushedHead = refs[0].localSha;
855
1343
  }
856
1344
  else {
1345
+ // Codex runs a user hook only once the person trusted it, so this one
1346
+ // running at all, push or not, says the push check is trusted there.
1347
+ if (args.userScope && hook === "codex")
1348
+ noteHookFired(options.environment, "codex", now().getTime(), "push-check");
857
1349
  const parsed = parseHookInput(input);
858
1350
  if (parsed?.command === undefined || !isPushCommand(parsed.command))
859
1351
  return 0;
1352
+ // The installer adds this command to both events; on any other event,
1353
+ // wired by hand, there is nothing for it to do.
1354
+ const called = parsed.eventName ?? "PreToolUse";
1355
+ if (called !== "PreToolUse" && called !== "PostToolUse")
1356
+ return 0;
1357
+ event = called;
1358
+ command = parsed.command;
860
1359
  if (parsed.cwd !== undefined && existsSync(parsed.cwd))
861
1360
  cwd = parsed.cwd;
1361
+ // Where the push leaves from first: the user-scope entry decides on
1362
+ // that repository, or on the session's own when the line does not say.
1363
+ place = pushPlace(parsed.command, cwd);
1364
+ if (place.known && existsSync(place.directory))
1365
+ cwd = place.directory;
1366
+ else
1367
+ place = { known: false };
1368
+ sessionId = parsed.sessionId;
1369
+ if (args.userScope &&
1370
+ !userScopeTakesThePush({
1371
+ cwd,
1372
+ host: hook,
1373
+ environment: options.environment,
1374
+ connected: deps.connected ??
1375
+ ((root) => connectionReader({
1376
+ environment: options.environment,
1377
+ controlPlane: args.controlPlane,
1378
+ cwd: root,
1379
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1380
+ }).ok),
1381
+ }))
1382
+ return 0;
1383
+ namedMerge = mergeTargets(parsed.command).find((target) => target.selector !== undefined || target.repo !== undefined);
862
1384
  }
863
1385
  }
1386
+ // Under the user-scope entry, a push from a place it cannot tell is spoken
1387
+ // of only where the session's own checkout has something to check: a
1388
+ // behavior map, or an agreed promise the server lists. Anywhere else it says
1389
+ // nothing, as it always has.
1390
+ const sessionHasPromises = async () => {
1391
+ const sessionRoot = checkoutRoot(cwd);
1392
+ if (sessionRoot === undefined)
1393
+ return false;
1394
+ if (readBehaviorMaps(join(sessionRoot, BEHAVIOR_MAP_DIRECTORY)).some((entry) => entry.map !== undefined))
1395
+ return true;
1396
+ let client = deps.anchors ?? undefined;
1397
+ if (deps.anchors === undefined) {
1398
+ const { connectionAnchorsClient } = await import("./anchor.js");
1399
+ const found = connectionAnchorsClient({
1400
+ environment: options.environment,
1401
+ controlPlane: args.controlPlane,
1402
+ cwd: sessionRoot,
1403
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1404
+ });
1405
+ if (found.ok)
1406
+ client = found.client;
1407
+ }
1408
+ if (client === undefined)
1409
+ return false;
1410
+ const listed = await client.list();
1411
+ return listed.ok && listed.promises.length > 0;
1412
+ };
1413
+ const pushedFromUnknown = async () => {
1414
+ if (args.userScope && !(await sessionHasPromises()))
1415
+ return 0;
1416
+ emit({
1417
+ step: "judge",
1418
+ status: "nothing_to_judge",
1419
+ reason: "repository_unknown",
1420
+ message: PUSH_REPOSITORY_UNKNOWN,
1421
+ exitCode: 0,
1422
+ });
1423
+ lines.push(PUSH_REPOSITORY_UNKNOWN);
1424
+ return respond(false);
1425
+ };
864
1426
  const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(cwd);
865
1427
  if (root === undefined || !existsSync(root)) {
866
1428
  if (hook !== undefined)
867
- return 0;
1429
+ return place?.known === false ? pushedFromUnknown() : 0;
868
1430
  const message = "This is not a git repository, so there is no change to judge.";
869
1431
  emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
870
1432
  if (!json)
@@ -882,7 +1444,7 @@ export async function runJudge(options) {
882
1444
  options.write(`${describeInstall(result)}\n`);
883
1445
  }
884
1446
  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');
1447
+ 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
1448
  if (targets.includes("codex"))
887
1449
  options.write("Codex runs a project hook once you trust it: open /hooks in Codex and review it.\n");
888
1450
  }
@@ -892,7 +1454,68 @@ export async function runJudge(options) {
892
1454
  const mode = args.mode ?? config.mode;
893
1455
  for (const problem of problems)
894
1456
  lines.push(`Balladeer judge: ${problem}.`);
1457
+ // Report mode judges after the command and block mode before it, so each
1458
+ // mode acts on one of the two events and lets the other go at once.
1459
+ if (hook === "claude" || hook === "codex") {
1460
+ if (event === "PostToolUse" && mode === "block")
1461
+ return 0;
1462
+ if (event === "PreToolUse" && mode === "block" && command !== undefined) {
1463
+ if (commitBeforePush(command) !== undefined) {
1464
+ lines.push(COMMIT_THEN_PUSH);
1465
+ return respond(true);
1466
+ }
1467
+ }
1468
+ if (event === "PreToolUse" && mode === "report") {
1469
+ // Setup writes the user-scope pair together, so its entry after the
1470
+ // push is there to judge; an earlier project install may have only this one.
1471
+ if (args.userScope || hasJudgeEntry(root, hook, "PostToolUse"))
1472
+ return 0;
1473
+ if (command !== undefined)
1474
+ commitFirst = commitBeforePush(command);
1475
+ // Installed by an earlier release, with no entry after the push: judge
1476
+ // 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
+ }
1479
+ }
1480
+ // Where the push leaves from: judged there, or not at all when that cannot be known,
1481
+ // and a pull request in another repository is not this checkout's to judge. After
1482
+ // the event gate, so each line is said once, on the event that acts.
1483
+ if (place?.known === false)
1484
+ return pushedFromUnknown();
1485
+ const elsewhere = place?.known ? await pullRequestElsewhere(cwd, place.sites) : undefined;
1486
+ if (elsewhere !== undefined) {
1487
+ const message = pullRequestElsewhereLine(elsewhere);
1488
+ emit({
1489
+ step: "judge",
1490
+ status: "nothing_to_judge",
1491
+ reason: "pull_request_elsewhere",
1492
+ message,
1493
+ exitCode: 0,
1494
+ });
1495
+ lines.push(message);
1496
+ return respond(false);
1497
+ }
895
1498
  await runCommand("git", ["-C", root, "worktree", "prune"]);
1499
+ if (namedMerge !== undefined && args.head === undefined) {
1500
+ const named = [namedMerge.repo, namedMerge.selector].filter(Boolean).join(" ");
1501
+ const target = await (deps.pullRequestHead ?? pullRequestHead)(root, namedMerge);
1502
+ const local = target === undefined ? undefined : await resolveCommit(root, target);
1503
+ if (local === undefined) {
1504
+ const message = target === undefined
1505
+ ? `Balladeer judge: could not read which commit pull request ${named} would merge, so it was not judged.`
1506
+ : `Balladeer judge: pull request ${named} merges ${target.slice(0, 12)}, which is not in this checkout, so it was not judged.`;
1507
+ emit({
1508
+ step: "judge",
1509
+ status: "nothing_to_judge",
1510
+ reason: "merge_elsewhere",
1511
+ message,
1512
+ exitCode: 0,
1513
+ });
1514
+ lines.push(message);
1515
+ return respond(false);
1516
+ }
1517
+ pushedHead = local;
1518
+ }
896
1519
  const head = await resolveCommit(root, args.head ?? pushedHead ?? "HEAD");
897
1520
  if (head === undefined) {
898
1521
  const message = `${args.head ?? "HEAD"} is not a commit here, so there is nothing to judge.`;
@@ -903,7 +1526,30 @@ export async function runJudge(options) {
903
1526
  options.error(`${message}\n`);
904
1527
  return 4;
905
1528
  }
906
- const base = args.base !== undefined ? await resolveCommit(root, args.base) : await defaultBase(root, head);
1529
+ // After the command, what was pushed is what is judged, so it has to have
1530
+ // reached the remote: Codex calls this hook after a push that failed, too.
1531
+ // A merge sends no commits of this checkout, so it has nothing to check.
1532
+ if (event === "PostToolUse" &&
1533
+ command !== undefined &&
1534
+ sendsCommits(command) &&
1535
+ namedMerge === undefined &&
1536
+ args.head === undefined &&
1537
+ !(await pushLanded(root, head))) {
1538
+ const message = `Balladeer judge: nothing was judged, because the push did not land: ${head.slice(0, 12)} is on no remote branch.`;
1539
+ emit({
1540
+ step: "judge",
1541
+ status: "nothing_to_judge",
1542
+ reason: "push_not_landed",
1543
+ message,
1544
+ exitCode: 0,
1545
+ });
1546
+ lines.push(message);
1547
+ return respond(false);
1548
+ }
1549
+ const base = args.base !== undefined
1550
+ ? await resolveCommit(root, args.base)
1551
+ : ((event === "PostToolUse" ? await baseBeforePush(root, head) : undefined) ??
1552
+ (await defaultBase(root, head)));
907
1553
  if (base === undefined || base === head) {
908
1554
  const message = base === undefined
909
1555
  ? `${args.base ?? "the base"} is not a commit here, so nothing was judged.`
@@ -954,6 +1600,99 @@ export async function runJudge(options) {
954
1600
  }
955
1601
  }
956
1602
  }
1603
+ // Meanings: the file, else this repository's connection, else the map's own excerpt.
1604
+ const connection = {
1605
+ environment: options.environment,
1606
+ controlPlane: args.controlPlane,
1607
+ cwd: root,
1608
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1609
+ };
1610
+ let reader = deps.reader ?? undefined;
1611
+ if (deps.reader === undefined && filePromise === undefined) {
1612
+ const found = connectionReader(connection);
1613
+ if (found.ok)
1614
+ reader = found.reader;
1615
+ }
1616
+ // The login is checked once, and only when something needs it: an anchoring call or a judge.
1617
+ const prefer = hook === "claude" || hook === "codex" ? hook : undefined;
1618
+ const requested = args.client ?? config.client;
1619
+ let loginCheck;
1620
+ const resolveLogin = () => (loginCheck ??= (deps.resolveLogin ??
1621
+ ((client, preferred) => resolveClient(client, {
1622
+ env: options.environment,
1623
+ cwd: root,
1624
+ ...(preferred === undefined ? {} : { prefer: preferred }),
1625
+ })))(requested, prefer));
1626
+ // From a hook, the host's own timeout rules and a timed-out hook lets the
1627
+ // push run, so the budget is capped below it; by hand, the person's number.
1628
+ // It starts here, so anchoring and judging share it.
1629
+ const budgetMs = args.wait
1630
+ ? undefined
1631
+ : effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000;
1632
+ const started = Date.now();
1633
+ // Anchors, brought current before anything is selected: read from Balladeer,
1634
+ // then cut from a current map or written by a call for each agreed promise
1635
+ // without them, within a third of the budget and today's anchorings. In
1636
+ // report mode this all runs after the push has landed; in block mode it runs
1637
+ // before, and a promise left unanchored is said and never holds the push.
1638
+ const named = filePromise !== undefined ? [filePromise.promiseId] : args.promises;
1639
+ const storedMaps = readBehaviorMaps(join(root, BEHAVIOR_MAP_DIRECTORY));
1640
+ let stage;
1641
+ // Loaded only here, once the push is this check's to judge, so the fast
1642
+ // exits above pay nothing for the anchoring machinery.
1643
+ const kit = filePromise === undefined ? await import("./anchor.js") : undefined;
1644
+ if (kit !== undefined) {
1645
+ let client = deps.anchors ?? undefined;
1646
+ let clientMissing;
1647
+ if (deps.anchors === undefined) {
1648
+ const found = kit.connectionAnchorsClient(connection);
1649
+ if (found.ok)
1650
+ client = found.client;
1651
+ else
1652
+ clientMissing = found.reason;
1653
+ }
1654
+ stage = await kit.anchorStage({
1655
+ root,
1656
+ head,
1657
+ environment: options.environment,
1658
+ maps: storedMaps,
1659
+ client,
1660
+ ...(clientMissing === undefined ? {} : { clientMissing }),
1661
+ reader,
1662
+ login: resolveLogin,
1663
+ now,
1664
+ deadline: started + (budgetMs === undefined ? ANCHORING_WAIT_MS : Math.floor(budgetMs / 3)),
1665
+ concurrency: config.concurrency,
1666
+ ...(named === undefined ? {} : { only: new Set(named) }),
1667
+ ...(deps.scratch === undefined ? {} : { scratch: deps.scratch }),
1668
+ ...(deps.anchorAgent === undefined ? {} : { runAgent: deps.anchorAgent }),
1669
+ ...(deps.graph === undefined ? {} : { graph: deps.graph }),
1670
+ ...(deps.anchorBudget === undefined ? {} : { budget: deps.anchorBudget }),
1671
+ });
1672
+ }
1673
+ const parsedMaps = storedMaps.flatMap((entry) => (entry.map === undefined ? [] : [entry.map]));
1674
+ // The user-scope entry still says nothing where there is nothing to check:
1675
+ // the server says this repository has no agreed promise, or the server
1676
+ // cannot be read and no promise here has a map.
1677
+ if (args.userScope &&
1678
+ stage !== undefined &&
1679
+ (stage.available ? stage.promises.length === 0 : parsedMaps.length === 0))
1680
+ return 0;
1681
+ // A map for its promise's current meaning wins over that promise's anchors.
1682
+ // Without the server's word on what is current, every map counts, as before.
1683
+ const mapsInUse = stage?.available ? [...stage.currentMaps.values()] : parsedMaps;
1684
+ const mappedIds = mapsInUse.map((map) => map.promiseId);
1685
+ // What each promise without such a map is judged on, and the anchor files it is selected by.
1686
+ const anchorsInUse = new Map();
1687
+ const selectBy = new Map();
1688
+ if (stage?.available) {
1689
+ for (const [promiseId, anchors] of stage.run?.anchors ?? [])
1690
+ if (!stage.currentMaps.has(promiseId))
1691
+ anchorsInUse.set(promiseId, anchors);
1692
+ for (const [promiseId, anchorFilesOf] of stage.run?.selectBy ?? [])
1693
+ if (!stage.currentMaps.has(promiseId))
1694
+ selectBy.set(promiseId, anchorFilesOf);
1695
+ }
957
1696
  if (config.offers && hook !== undefined) {
958
1697
  // Even when no promise is at risk and no map exists: a change nobody
959
1698
  // mapped is exactly where a rule nobody wrote down is likeliest.
@@ -975,60 +1714,109 @@ export async function runJudge(options) {
975
1714
  ...(deps.offerAgent === undefined ? {} : { runAgent: deps.offerAgent }),
976
1715
  });
977
1716
  }
978
- const mappedIds = readBehaviorMaps(join(root, ".continuity/behavior-maps"))
979
- .filter((entry) => entry.map !== undefined)
980
- .map((entry) => entry.promiseId);
981
- const named = filePromise !== undefined ? [filePromise.promiseId] : args.promises;
1717
+ // By anchors: every promise one of whose anchor files the change edits, a
1718
+ // moved or deleted file counting under its old name too.
1719
+ const anchored = kit !== undefined && named === undefined && selectBy.size > 0
1720
+ ? kit.anchoredByChange(selectBy, await changedPathsBothSides(root, base, head))
1721
+ : [];
1722
+ // Their meanings, read once: the scorer orders them by their words, and
1723
+ // their judges need them anyway.
1724
+ const meanings = new Map();
1725
+ if (reader !== undefined)
1726
+ for (const promiseId of anchored) {
1727
+ const read = await reader.get(promiseId);
1728
+ if (read.ok)
1729
+ meanings.set(promiseId, read.promise);
1730
+ }
1731
+ // A promise all of whose anchors have gone is still selected by them, and is
1732
+ // listed after those the ranking can place.
1733
+ const scored = [
1734
+ ...mapsInUse,
1735
+ ...anchored.flatMap((promiseId) => {
1736
+ const anchors = anchorsInUse.get(promiseId);
1737
+ const meaning = meanings.get(promiseId);
1738
+ return anchors === undefined || kit === undefined
1739
+ ? []
1740
+ : [kit.anchorsAsMap(anchors, meaning === undefined ? undefined : meaningExcerpt(meaning))];
1741
+ }),
1742
+ ];
1743
+ const repository = stage?.available ? stage.repository : undefined;
982
1744
  const risk = named === undefined
983
- ? await (deps.riskReport ?? ((r, b) => riskFromCommand(r, b, options.environment)))(root, base, head)
1745
+ ? await (deps.riskReport ??
1746
+ ((r, b, h, m) => riskInProcess(r, b, h, m, repository)))(root, base, head, scored)
984
1747
  : undefined;
985
1748
  const selected = selectPromises({
986
1749
  ...(named === undefined ? {} : { named }),
987
1750
  ...(risk === undefined ? {} : { risk }),
988
1751
  mapped: mappedIds,
1752
+ anchored,
989
1753
  // A promise handed over in a file is judged alone: the repository's own
990
1754
  // standing list belongs to its own promises, not to the one in the file.
991
1755
  config: filePromise === undefined ? config : { ...config, alwaysJudge: [] },
992
1756
  ...(args.topK === undefined ? {} : { topK: args.topK }),
993
1757
  random: deps.random ?? Math.random,
994
1758
  });
1759
+ // What anchoring did, said after the verdicts whenever it ran or was held back.
1760
+ const anchorRun = stage?.available ? stage.run : undefined;
1761
+ if (anchorRun !== undefined && kit !== undefined) {
1762
+ const said = kit.anchoringLines(anchorRun, {
1763
+ push: true,
1764
+ judged: new Set(selected.map((entry) => entry.promiseId)),
1765
+ });
1766
+ if (said.length > 0) {
1767
+ trailing.push(...said);
1768
+ emit({
1769
+ step: "anchors",
1770
+ message: said.join(" "),
1771
+ anchored: anchorRun.outcomes
1772
+ .filter((outcome) => outcome.status === "anchored" || outcome.status === "seeded")
1773
+ .map((outcome) => outcome.promiseId),
1774
+ notAnchored: anchorRun.outcomes
1775
+ .filter((outcome) => !["current", "anchored", "seeded", "stale"].includes(outcome.status))
1776
+ .map((outcome) => ({
1777
+ promiseId: outcome.promiseId,
1778
+ reason: outcome.reason ?? outcome.status,
1779
+ })),
1780
+ tokens: anchorRun.tokens,
1781
+ remainingToday: anchorRun.budget.remaining(),
1782
+ });
1783
+ }
1784
+ }
995
1785
  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 });
998
- lines.push(message);
1786
+ // Said in one line either way, so that silence is never read as safe.
1787
+ const nothing = nothingSelected({
1788
+ stage,
1789
+ maps: mapsInUse.length,
1790
+ anchors: selectBy.size,
1791
+ push: hook !== undefined,
1792
+ });
1793
+ emit({
1794
+ step: "judge",
1795
+ status: "nothing_to_judge",
1796
+ reason: nothing.reason,
1797
+ message: nothing.message,
1798
+ exitCode: 0,
1799
+ });
1800
+ lines.push(nothing.message);
999
1801
  return respond(false);
1000
1802
  }
1001
- const prefer = hook === "claude" || hook === "codex" ? hook : undefined;
1002
- const requested = args.client ?? config.client;
1003
- const login = await (deps.resolveLogin ??
1004
- ((client, preferred) => resolveClient(client, {
1005
- env: options.environment,
1006
- cwd: root,
1007
- ...(preferred === undefined ? {} : { prefer: preferred }),
1008
- })))(requested, prefer);
1803
+ const login = await resolveLogin();
1009
1804
  if (login === undefined) {
1010
1805
  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.";
1011
1806
  emit({ step: "judge", status: "not_judged", reason: "no_login", message, exitCode: 0 });
1012
1807
  lines.push(message);
1013
1808
  return respond(false);
1014
1809
  }
1015
- // Meanings: the file, else this repository's connection, else the map's own excerpt.
1016
- let reader = deps.reader ?? undefined;
1017
- if (deps.reader === undefined && filePromise === undefined) {
1018
- const connection = connectionReader({
1019
- environment: options.environment,
1020
- controlPlane: args.controlPlane,
1021
- cwd: root,
1022
- ...(args.repository === undefined ? {} : { repository: args.repository }),
1023
- });
1024
- if (connection.ok)
1025
- reader = connection.reader;
1026
- }
1027
1810
  const jobs = [];
1028
1811
  const unreadable = [];
1029
1812
  for (const { promiseId } of selected) {
1030
- const map = filePromise !== undefined ? fileMap : readBehaviorMap(root, promiseId);
1031
- let promise = filePromise;
1813
+ const map = filePromise !== undefined
1814
+ ? fileMap
1815
+ : stage?.available
1816
+ ? stage.currentMaps.get(promiseId)
1817
+ : readBehaviorMap(root, promiseId);
1818
+ const anchors = map === undefined ? anchorsInUse.get(promiseId) : undefined;
1819
+ let promise = filePromise ?? meanings.get(promiseId);
1032
1820
  if (promise === undefined && reader !== undefined) {
1033
1821
  const read = await reader.get(promiseId);
1034
1822
  if (read.ok)
@@ -1039,7 +1827,13 @@ export async function runJudge(options) {
1039
1827
  if (promise === undefined)
1040
1828
  unreadable.push(promiseId);
1041
1829
  else
1042
- jobs.push({ promise, ...(map === undefined ? {} : { map }) });
1830
+ jobs.push({
1831
+ promise,
1832
+ ...(map === undefined ? {} : { map }),
1833
+ ...(anchors === undefined || repository === undefined || kit === undefined
1834
+ ? {}
1835
+ : { anchors: kit.judgeAnchors(anchors, repository) }),
1836
+ });
1043
1837
  }
1044
1838
  const stopHandler = () => {
1045
1839
  removeLiveWorktreesNow();
@@ -1048,13 +1842,9 @@ export async function runJudge(options) {
1048
1842
  process.once("SIGINT", stopHandler);
1049
1843
  process.once("SIGTERM", stopHandler);
1050
1844
  const budget = new AbortController();
1051
- // From a hook, the host's own timeout rules and a timed-out hook lets the
1052
- // push run, so the budget is capped below it; by hand, the person's number.
1053
- const budgetMs = args.wait
1845
+ const timer = budgetMs === undefined
1054
1846
  ? undefined
1055
- : effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000;
1056
- const timer = budgetMs === undefined ? undefined : setTimeout(() => budget.abort(), budgetMs);
1057
- const started = Date.now();
1847
+ : setTimeout(() => budget.abort(), Math.max(1000, budgetMs - (Date.now() - started)));
1058
1848
  const verdicts = [];
1059
1849
  const stillJudging = [];
1060
1850
  const notJudged = [];
@@ -1072,7 +1862,12 @@ export async function runJudge(options) {
1072
1862
  head,
1073
1863
  }, now());
1074
1864
  if (logged !== undefined) {
1075
- verdicts.push({ verdict: logged, title: job.promise.title, reused: true });
1865
+ verdicts.push({
1866
+ verdict: logged,
1867
+ title: job.promise.title,
1868
+ reused: true,
1869
+ checkBound: job.promise.verifierBound,
1870
+ });
1076
1871
  continue;
1077
1872
  }
1078
1873
  if (budget.signal.aborted) {
@@ -1084,6 +1879,7 @@ export async function runJudge(options) {
1084
1879
  root,
1085
1880
  promise: job.promise,
1086
1881
  ...(job.map === undefined ? {} : { map: job.map }),
1882
+ ...(job.anchors === undefined ? {} : { anchors: job.anchors }),
1087
1883
  base,
1088
1884
  head,
1089
1885
  changedFiles: files,
@@ -1099,7 +1895,12 @@ export async function runJudge(options) {
1099
1895
  else if (outcome.status === "not_judged")
1100
1896
  notJudged.push({ id: job.promise.promiseId, reason: outcome.reason });
1101
1897
  else {
1102
- verdicts.push({ verdict: outcome.verdict, title: job.promise.title, reused: false });
1898
+ verdicts.push({
1899
+ verdict: outcome.verdict,
1900
+ title: job.promise.title,
1901
+ reused: false,
1902
+ checkBound: job.promise.verifierBound,
1903
+ });
1103
1904
  appendVerdicts(root, [outcome.verdict], now());
1104
1905
  }
1105
1906
  }
@@ -1119,8 +1920,15 @@ export async function runJudge(options) {
1119
1920
  for (const { verdict } of verdicts)
1120
1921
  options.write(`${JSON.stringify(verdict)}\n`);
1121
1922
  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)}`);
1923
+ // A could-not-tell this session already heard in full comes back as one quiet line.
1924
+ const told = sessionMemory(join(root, JUDGE_LOG_DIRECTORY), sessionId, now());
1925
+ for (const { verdict, title, reused, checkBound } of verdicts)
1926
+ lines.push(` ${told?.repeat(verdict, title) ??
1927
+ verdictLine(stillKept(root, verdict), title, {
1928
+ reused,
1929
+ ...(checkBound === undefined ? {} : { checkBound }),
1930
+ })}`);
1931
+ told?.save();
1124
1932
  for (const { id, reason } of notJudged)
1125
1933
  lines.push(` not judged: ${id}: ${reason}`);
1126
1934
  for (const id of unreadable)