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.
- 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.d.ts +1 -1
- package/dist/cli.js +24 -7
- 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/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +219 -12
- package/dist/commands/judge.js +911 -103
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +244 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +282 -95
- 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/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +33 -3
- package/dist/judge-brief.js +39 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +25 -0
- package/dist/promise-meaning.js +24 -7
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- 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/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +19 -3
- package/dist/wire.js +2 -2
- package/package.json +7 -2
package/dist/commands/judge.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
/**
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
});
|
|
615
|
-
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() !== "") : [];
|
|
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
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
*
|
|
695
|
-
* so a verdict
|
|
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
|
-
|
|
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
|
-
/**
|
|
740
|
-
export
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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 ??
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
|
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
|
|
1031
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
:
|
|
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({
|
|
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({
|
|
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
|
-
|
|
1123
|
-
|
|
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)
|