balladeer 1.0.17 → 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.
@@ -1,6 +1,6 @@
1
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";
@@ -9,7 +9,7 @@ import { baseBeforePush, checkoutRoot, commitBeforePush, effectiveBudgetSeconds,
9
9
  import { noteHookFired } from "../hook-trust.js";
10
10
  import { sessionMemory } from "../judge-said.js";
11
11
  import { runOwnedProcess, tail } from "../owned-process.js";
12
- import { caseIds, connectionReader, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
12
+ import { caseIds, connectionReader, meaningExcerpt, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
13
13
  import { addScratchWorktree, carryFiles, newFiles, removeLiveWorktreesNow, resolveCommit, shareDependencies, } from "../scratch-worktree.js";
14
14
  import { normalizeControlPlane, StoreError } from "../store.js";
15
15
  import { directness, riskOrder } from "../risk/score.js";
@@ -43,6 +43,8 @@ export const STILL_JUDGING = "still judging; rerun `balladeer judge --wait`";
43
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.";
44
44
  /** However long `--wait` allows, one judge is stopped after this. */
45
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;
46
48
  const LINKED_TEST_TIMEOUT_MS = 3 * 60_000;
47
49
  const REPRODUCTION_TIMEOUT_MS = 5 * 60_000;
48
50
  export const DEFAULT_JUDGE_CONFIG = {
@@ -375,7 +377,7 @@ export async function judgePromise(input) {
375
377
  await shareDependencies(input.root, headTree.path);
376
378
  let linkedTestRun = { file: "", outcome: "none" };
377
379
  const linkedTestRuns = [];
378
- const firstTest = input.map?.linkedTests[0];
380
+ const firstTest = input.map !== undefined ? input.map.linkedTests[0] : input.anchors?.linkedTests[0];
379
381
  if (firstTest !== undefined) {
380
382
  const command = linkedTestCommand(headTree.path, firstTest.file);
381
383
  if (command === undefined)
@@ -409,6 +411,9 @@ export async function judgePromise(input) {
409
411
  prompt: judgePrompt({
410
412
  promise: input.promise,
411
413
  ...(input.map === undefined ? {} : { map: input.map }),
414
+ ...(input.map !== undefined || input.anchors === undefined
415
+ ? {}
416
+ : { anchors: input.anchors }),
412
417
  changedFiles: input.changedFiles,
413
418
  base: input.base,
414
419
  head: input.head,
@@ -449,6 +454,7 @@ export async function judgePromise(input) {
449
454
  judge,
450
455
  briefRevision: JUDGE_BRIEF_REVISION,
451
456
  mapUsed: input.map !== undefined,
457
+ anchorsUsed: input.map === undefined && input.anchors !== undefined,
452
458
  linkedTestRun,
453
459
  ...fields,
454
460
  });
@@ -558,10 +564,13 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
558
564
  --block exits 2 on a reproduced break, and when judges are still running
559
565
  at the time limit it holds the push rather than letting it through.
560
566
  --wait runs without the time limit. Promises come from --promises, else
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
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
565
574
  .continuity/judge-log/, which git ignores. --install-hook adds the push
566
575
  hook to .claude/settings.json and .codex/hooks.json, before and after
567
576
  each shell command: report mode judges after a push has landed, so the
@@ -572,7 +581,7 @@ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--to
572
581
  whoever is pushing; it records nothing and never holds a push (see
573
582
  \`balladeer offers\`). Setup also writes the same pair of entries at
574
583
  each host's user scope, running \`judge --hook <host> --user-scope\`,
575
- so the check runs in every connected repository with mapped promises
584
+ so the check runs in every connected repository with agreed promises
576
585
  and says nothing anywhere else; a checkout with its own entry keeps
577
586
  the check to that entry. --push-check off turns the user-scope check
578
587
  off on this laptop, and --push-check on turns it back on.
@@ -793,20 +802,43 @@ export function readRiskOutput(stdout) {
793
802
  }
794
803
  return undefined;
795
804
  }
796
- /** `balladeer risk --json`, run as a child of this same command, when this build has it. */
797
- async function riskFromCommand(root, base, env) {
798
- const entry = process.argv[1];
799
- 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 {
800
832
  return undefined;
801
- const run = await runOwnedProcess({
802
- command: process.execPath,
803
- args: [...process.execArgv, entry, "risk", "--json", "--base", base],
804
- cwd: root,
805
- env: { ...env, [JUDGE_ACTIVE_VARIABLE]: "1" },
806
- timeoutMs: 60_000,
807
- maxOutputBytes: 4 * 1024 * 1024,
808
- });
809
- 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() !== "") : [];
810
842
  }
811
843
  /**
812
844
  * The commit a change is measured from: the fork point with the remote default
@@ -842,20 +874,30 @@ export async function changedFiles(root, base, head) {
842
874
  return diff.ok ? diff.stdout.split("\n").filter((line) => line.trim() !== "") : [];
843
875
  }
844
876
  /**
845
- * Which promises to judge: named ones, else from the risk report, else every
846
- * mapped promise; then `alwaysJudge`.
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):
847
889
  *
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):
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.
851
897
  *
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.
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.
859
901
  */
860
902
  export function selectPromises(input) {
861
903
  const out = [];
@@ -866,31 +908,52 @@ export function selectPromises(input) {
866
908
  seen.add(promiseId);
867
909
  out.push({ promiseId, why });
868
910
  };
911
+ const anchored = new Set(input.anchored ?? []);
869
912
  if (input.named !== undefined)
870
913
  for (const id of input.named)
871
914
  add(id, "named");
872
915
  else if (input.risk !== undefined) {
873
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));
874
925
  if (input.config.select === "direct") {
875
- const direct = ordered.filter((entry) => tierOf(entry) === 3);
926
+ const direct = inOrder([
927
+ ...mapped
928
+ .filter((entry) => tierOf(entry) === 3)
929
+ .map((entry) => ({ promiseId: entry.promiseId, why: "risk" })),
930
+ ...byAnchors,
931
+ ]);
876
932
  const capped = input.topK === undefined ? direct : direct.slice(0, input.topK);
877
933
  for (const entry of capped)
878
- add(entry.promiseId, "risk");
934
+ add(entry.promiseId, entry.why);
879
935
  }
880
936
  else {
881
937
  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));
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));
886
946
  for (const entry of below)
887
947
  if (input.random() < input.config.sampleBelowLine)
888
948
  add(entry.promiseId, "sample");
889
949
  }
890
950
  }
891
- else
951
+ else {
892
952
  for (const id of input.mapped)
893
953
  add(id, "mapped");
954
+ for (const id of anchored)
955
+ add(id, "anchored");
956
+ }
894
957
  for (const id of input.config.alwaysJudge)
895
958
  add(id, "always");
896
959
  return out;
@@ -1113,17 +1176,22 @@ function describeInstall(result) {
1113
1176
  }
1114
1177
  /** What `--push-check off` and `--push-check on` say. */
1115
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`.";
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.";
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.";
1117
1180
  /**
1118
1181
  * Whether the push check's user-scope entry has this push to itself, decided
1119
1182
  * before anything is started or fetched: the entry runs on every shell command
1120
1183
  * in every folder on the laptop, and most folders have nothing to judge. It
1121
1184
  * 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
1185
+ * folder is in no checkout, or the checkout has the push check's own project
1123
1186
  * 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.
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.
1127
1195
  */
1128
1196
  export function userScopeTakesThePush(input) {
1129
1197
  if (pushCheckOff(input.environment))
@@ -1133,8 +1201,41 @@ export function userScopeTakesThePush(input) {
1133
1201
  return false;
1134
1202
  if (hasProjectJudgeHook(root, input.host))
1135
1203
  return false;
1136
- const mapped = readBehaviorMaps(join(root, ".continuity/behavior-maps")).some((entry) => entry.map !== undefined);
1137
- return mapped && input.connected(root);
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
+ };
1138
1239
  }
1139
1240
  /** `balladeer judge`, from arguments already parsed. */
1140
1241
  /**
@@ -1282,7 +1383,36 @@ export async function runJudge(options) {
1282
1383
  namedMerge = mergeTargets(parsed.command).find((target) => target.selector !== undefined || target.repo !== undefined);
1283
1384
  }
1284
1385
  }
1285
- const pushedFromUnknown = () => {
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;
1286
1416
  emit({
1287
1417
  step: "judge",
1288
1418
  status: "nothing_to_judge",
@@ -1470,6 +1600,99 @@ export async function runJudge(options) {
1470
1600
  }
1471
1601
  }
1472
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
+ }
1473
1696
  if (config.offers && hook !== undefined) {
1474
1697
  // Even when no promise is at risk and no map exists: a change nobody
1475
1698
  // mapped is exactly where a rule nobody wrote down is likeliest.
@@ -1491,70 +1714,109 @@ export async function runJudge(options) {
1491
1714
  ...(deps.offerAgent === undefined ? {} : { runAgent: deps.offerAgent }),
1492
1715
  });
1493
1716
  }
1494
- const mappedIds = readBehaviorMaps(join(root, ".continuity/behavior-maps"))
1495
- .filter((entry) => entry.map !== undefined)
1496
- .map((entry) => entry.promiseId);
1497
- 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;
1498
1744
  const risk = named === undefined
1499
- ? 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)
1500
1747
  : undefined;
1501
1748
  const selected = selectPromises({
1502
1749
  ...(named === undefined ? {} : { named }),
1503
1750
  ...(risk === undefined ? {} : { risk }),
1504
1751
  mapped: mappedIds,
1752
+ anchored,
1505
1753
  // A promise handed over in a file is judged alone: the repository's own
1506
1754
  // standing list belongs to its own promises, not to the one in the file.
1507
1755
  config: filePromise === undefined ? config : { ...config, alwaysJudge: [] },
1508
1756
  ...(args.topK === undefined ? {} : { topK: args.topK }),
1509
1757
  random: deps.random ?? Math.random,
1510
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
+ }
1511
1785
  if (selected.length === 0) {
1512
1786
  // 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.`;
1787
+ const nothing = nothingSelected({
1788
+ stage,
1789
+ maps: mapsInUse.length,
1790
+ anchors: selectBy.size,
1791
+ push: hook !== undefined,
1792
+ });
1517
1793
  emit({
1518
1794
  step: "judge",
1519
1795
  status: "nothing_to_judge",
1520
- reason: noMaps ? "no_maps" : "no_promise_touched",
1521
- message,
1796
+ reason: nothing.reason,
1797
+ message: nothing.message,
1522
1798
  exitCode: 0,
1523
1799
  });
1524
- lines.push(message);
1800
+ lines.push(nothing.message);
1525
1801
  return respond(false);
1526
1802
  }
1527
- const prefer = hook === "claude" || hook === "codex" ? hook : undefined;
1528
- const requested = args.client ?? config.client;
1529
- const login = await (deps.resolveLogin ??
1530
- ((client, preferred) => resolveClient(client, {
1531
- env: options.environment,
1532
- cwd: root,
1533
- ...(preferred === undefined ? {} : { prefer: preferred }),
1534
- })))(requested, prefer);
1803
+ const login = await resolveLogin();
1535
1804
  if (login === undefined) {
1536
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.";
1537
1806
  emit({ step: "judge", status: "not_judged", reason: "no_login", message, exitCode: 0 });
1538
1807
  lines.push(message);
1539
1808
  return respond(false);
1540
1809
  }
1541
- // Meanings: the file, else this repository's connection, else the map's own excerpt.
1542
- let reader = deps.reader ?? undefined;
1543
- if (deps.reader === undefined && filePromise === undefined) {
1544
- const connection = connectionReader({
1545
- environment: options.environment,
1546
- controlPlane: args.controlPlane,
1547
- cwd: root,
1548
- ...(args.repository === undefined ? {} : { repository: args.repository }),
1549
- });
1550
- if (connection.ok)
1551
- reader = connection.reader;
1552
- }
1553
1810
  const jobs = [];
1554
1811
  const unreadable = [];
1555
1812
  for (const { promiseId } of selected) {
1556
- const map = filePromise !== undefined ? fileMap : readBehaviorMap(root, promiseId);
1557
- 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);
1558
1820
  if (promise === undefined && reader !== undefined) {
1559
1821
  const read = await reader.get(promiseId);
1560
1822
  if (read.ok)
@@ -1565,7 +1827,13 @@ export async function runJudge(options) {
1565
1827
  if (promise === undefined)
1566
1828
  unreadable.push(promiseId);
1567
1829
  else
1568
- 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
+ });
1569
1837
  }
1570
1838
  const stopHandler = () => {
1571
1839
  removeLiveWorktreesNow();
@@ -1574,13 +1842,9 @@ export async function runJudge(options) {
1574
1842
  process.once("SIGINT", stopHandler);
1575
1843
  process.once("SIGTERM", stopHandler);
1576
1844
  const budget = new AbortController();
1577
- // From a hook, the host's own timeout rules and a timed-out hook lets the
1578
- // push run, so the budget is capped below it; by hand, the person's number.
1579
- const budgetMs = args.wait
1845
+ const timer = budgetMs === undefined
1580
1846
  ? undefined
1581
- : effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000;
1582
- const timer = budgetMs === undefined ? undefined : setTimeout(() => budget.abort(), budgetMs);
1583
- const started = Date.now();
1847
+ : setTimeout(() => budget.abort(), Math.max(1000, budgetMs - (Date.now() - started)));
1584
1848
  const verdicts = [];
1585
1849
  const stillJudging = [];
1586
1850
  const notJudged = [];
@@ -1615,6 +1879,7 @@ export async function runJudge(options) {
1615
1879
  root,
1616
1880
  promise: job.promise,
1617
1881
  ...(job.map === undefined ? {} : { map: job.map }),
1882
+ ...(job.anchors === undefined ? {} : { anchors: job.anchors }),
1618
1883
  base,
1619
1884
  head,
1620
1885
  changedFiles: files,
@@ -221,6 +221,8 @@ export function agentFailure(agent) {
221
221
  return "the agent finished without an answer";
222
222
  case "no_json":
223
223
  return "the agent's answer held no JSON object";
224
+ case "output_capped":
225
+ return `the agent wrote ${agent.outputTokens ?? "more"} output tokens, past its cap, so its answer was not used`;
224
226
  default:
225
227
  return "the agent did not answer";
226
228
  }