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