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.
- package/dist/anchor-budget.d.ts +40 -0
- package/dist/anchor-budget.js +110 -0
- package/dist/anchoring.d.ts +241 -0
- package/dist/anchoring.js +766 -0
- package/dist/anchors-client.d.ts +62 -0
- package/dist/anchors-client.js +197 -0
- package/dist/anchors-schema.d.ts +85 -0
- package/dist/anchors-schema.js +246 -0
- package/dist/cli.js +5 -1
- package/dist/commands/anchor-usage.d.ts +6 -0
- package/dist/commands/anchor-usage.js +19 -0
- package/dist/commands/anchor.d.ts +127 -0
- package/dist/commands/anchor.js +303 -0
- package/dist/commands/judge.d.ts +69 -21
- package/dist/commands/judge.js +354 -89
- package/dist/commands/map.js +2 -0
- package/dist/guidance-hook.mjs +5 -2
- package/dist/guidance.d.ts +7 -0
- package/dist/guidance.js +10 -1
- package/dist/headless-agent.d.ts +19 -1
- package/dist/headless-agent.js +22 -5
- package/dist/judge-brief.d.ts +20 -2
- package/dist/judge-brief.js +26 -2
- package/dist/promise-meaning.d.ts +17 -0
- package/dist/promise-meaning.js +15 -6
- package/dist/risk/git.d.ts +3 -1
- package/dist/risk/git.js +3 -3
- package/dist/risk/graph-cache.d.ts +83 -0
- package/dist/risk/graph-cache.js +291 -0
- package/dist/risk/import-graph.d.ts +43 -0
- package/dist/risk/import-graph.js +88 -34
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/wire.d.ts +18 -2
- package/dist/wire.js +1 -1
- package/package.json +7 -2
package/dist/commands/judge.js
CHANGED
|
@@ -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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
|
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
|
-
/**
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
});
|
|
809
|
-
return
|
|
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
|
|
846
|
-
*
|
|
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
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
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
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
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 =
|
|
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,
|
|
934
|
+
add(entry.promiseId, entry.why);
|
|
879
935
|
}
|
|
880
936
|
else {
|
|
881
937
|
const k = input.topK ?? input.config.topK;
|
|
882
|
-
const top =
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
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)
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
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
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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 ??
|
|
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
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
:
|
|
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:
|
|
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
|
|
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
|
|
1557
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
:
|
|
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,
|
package/dist/commands/map.js
CHANGED
|
@@ -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
|
}
|