balladeer 1.0.15 → 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.
Files changed (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +6 -1
  5. package/dist/cli.js +74 -6
  6. package/dist/commands/guidance.d.ts +2 -1
  7. package/dist/commands/guidance.js +55 -0
  8. package/dist/commands/judge.d.ts +430 -0
  9. package/dist/commands/judge.js +1713 -0
  10. package/dist/commands/map.d.ts +164 -0
  11. package/dist/commands/map.js +838 -0
  12. package/dist/commands/mcp.js +2 -2
  13. package/dist/commands/offers.d.ts +265 -0
  14. package/dist/commands/offers.js +801 -0
  15. package/dist/commands/risk.d.ts +29 -0
  16. package/dist/commands/risk.js +133 -0
  17. package/dist/copy.d.ts +24 -1
  18. package/dist/copy.js +91 -0
  19. package/dist/guidance-hook.mjs +348 -94
  20. package/dist/headless-agent.d.ts +166 -0
  21. package/dist/headless-agent.js +416 -0
  22. package/dist/hook-trust.d.ts +41 -9
  23. package/dist/hook-trust.js +98 -16
  24. package/dist/judge-brief.d.ts +48 -0
  25. package/dist/judge-brief.js +102 -0
  26. package/dist/judge-hook.d.ts +258 -0
  27. package/dist/judge-hook.js +1278 -0
  28. package/dist/judge-said.d.ts +52 -0
  29. package/dist/judge-said.js +181 -0
  30. package/dist/map-brief.d.ts +16 -0
  31. package/dist/map-brief.js +41 -0
  32. package/dist/offer-brief.d.ts +95 -0
  33. package/dist/offer-brief.js +217 -0
  34. package/dist/owned-process.d.ts +65 -0
  35. package/dist/owned-process.js +146 -0
  36. package/dist/promise-meaning.d.ts +134 -0
  37. package/dist/promise-meaning.js +300 -0
  38. package/dist/relay.d.ts +71 -0
  39. package/dist/relay.js +193 -0
  40. package/dist/remove-earlier.js +4 -2
  41. package/dist/risk/contract.d.ts +145 -0
  42. package/dist/risk/contract.js +74 -0
  43. package/dist/risk/describe.d.ts +7 -0
  44. package/dist/risk/describe.js +45 -0
  45. package/dist/risk/diff.d.ts +26 -0
  46. package/dist/risk/diff.js +174 -0
  47. package/dist/risk/extract.d.ts +43 -0
  48. package/dist/risk/extract.js +336 -0
  49. package/dist/risk/git.d.ts +30 -0
  50. package/dist/risk/git.js +118 -0
  51. package/dist/risk/import-graph.d.ts +41 -0
  52. package/dist/risk/import-graph.js +487 -0
  53. package/dist/risk/index.d.ts +20 -0
  54. package/dist/risk/index.js +20 -0
  55. package/dist/risk/paths.d.ts +16 -0
  56. package/dist/risk/paths.js +73 -0
  57. package/dist/risk/pipeline.d.ts +47 -0
  58. package/dist/risk/pipeline.js +121 -0
  59. package/dist/risk/priors.d.ts +18 -0
  60. package/dist/risk/priors.js +86 -0
  61. package/dist/risk/resources.d.ts +56 -0
  62. package/dist/risk/resources.js +374 -0
  63. package/dist/risk/score.d.ts +95 -0
  64. package/dist/risk/score.js +552 -0
  65. package/dist/risk/symbols.d.ts +35 -0
  66. package/dist/risk/symbols.js +348 -0
  67. package/dist/risk/text.d.ts +43 -0
  68. package/dist/risk/text.js +277 -0
  69. package/dist/risk/validate.d.ts +19 -0
  70. package/dist/risk/validate.js +154 -0
  71. package/dist/scratch-worktree.d.ts +55 -0
  72. package/dist/scratch-worktree.js +160 -0
  73. package/dist/user-scope.d.ts +74 -3
  74. package/dist/user-scope.js +270 -9
  75. package/dist/wire.d.ts +52 -3
  76. package/dist/wire.js +2 -2
  77. package/package.json +1 -1
@@ -0,0 +1,1713 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { CASE_ID, PROMISE_ID, parseBehaviorMap } from "../behavior-map-schema.js";
4
+ import { runCommand } from "../gh.js";
5
+ import { repositoryRoot } from "../git.js";
6
+ import { agentEnvironment, resolveClient, runHeadlessAgent, JUDGE_ACTIVE_VARIABLE, } from "../headless-agent.js";
7
+ import { JUDGE_BRIEF_REVISION, judgePrompt } from "../judge-brief.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";
11
+ import { runOwnedProcess, tail } from "../owned-process.js";
12
+ import { caseIds, connectionReader, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
13
+ import { addScratchWorktree, carryFiles, newFiles, removeLiveWorktreesNow, resolveCommit, shareDependencies, } from "../scratch-worktree.js";
14
+ import { normalizeControlPlane, StoreError } from "../store.js";
15
+ import { directness, riskOrder } from "../risk/score.js";
16
+ import { CLI_INVOCATION, DEFAULT_CONTROL_PLANE } from "../wire.js";
17
+ import { AGENT_TOOLS, REFUSED_TOOL_RULES, agentFailure, readBehaviorMap, readBehaviorMaps, } from "./map.js";
18
+ import { offersForPush } from "./offers.js";
19
+ /**
20
+ * `balladeer judge`: for a change about to leave this machine, ask the person's
21
+ * own coding agent, one promise at a time, whether the change keeps it.
22
+ *
23
+ * A verdict is only as good as its evidence, so the runner does not take a
24
+ * judge's word for a break. A judge that says "broken" must hand back a command,
25
+ * and the runner runs that command itself, once at head and once in a clean
26
+ * checkout of base: it must fail with the change and pass without it. Anything
27
+ * short of that is "could not tell", with the reason kept. Nothing here ever
28
+ * says a promise is protected; a judge's "kept" is a local reading of one
29
+ * change, not a check that holds.
30
+ */
31
+ export const JUDGE_VERDICT_SCHEMA_VERSION = "balladeer-judge-verdict/v1";
32
+ export const JUDGE_CONFIG_SCHEMA_VERSION = "balladeer-judge-config/v1";
33
+ export const JUDGE_CONFIG_PATH = ".continuity/judge.json";
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";
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.";
44
+ /** However long `--wait` allows, one judge is stopped after this. */
45
+ const JUDGE_CAP_MS = 15 * 60_000;
46
+ const LINKED_TEST_TIMEOUT_MS = 3 * 60_000;
47
+ const REPRODUCTION_TIMEOUT_MS = 5 * 60_000;
48
+ export const DEFAULT_JUDGE_CONFIG = {
49
+ schemaVersion: JUDGE_CONFIG_SCHEMA_VERSION,
50
+ mode: "report",
51
+ topK: 8,
52
+ concurrency: 3,
53
+ budgetSeconds: 240,
54
+ sampleBelowLine: 0.1,
55
+ alwaysJudge: [],
56
+ client: "auto",
57
+ select: "direct",
58
+ offers: false,
59
+ offersOn: "fixes",
60
+ maxOffers: 1,
61
+ };
62
+ /**
63
+ * Whether the configuration turns offers on with the key the first exploration
64
+ * used. That key is refused rather than read: its word is one nobody should be
65
+ * asked to learn, and a switch that starts a reading on every push is not one
66
+ * to keep honoring under an old name.
67
+ */
68
+ function usesRetiredOffersKey(input) {
69
+ return input["candidates"] !== undefined;
70
+ }
71
+ /**
72
+ * `.continuity/judge.json`, with the contract's defaults for anything absent.
73
+ * A field that is present and wrong keeps its default and is named, so a typo
74
+ * in a team's config is visible rather than quietly ignored.
75
+ */
76
+ export function readJudgeConfig(root) {
77
+ const path = join(root, JUDGE_CONFIG_PATH);
78
+ if (!existsSync(path))
79
+ return { config: DEFAULT_JUDGE_CONFIG, problems: [] };
80
+ let raw;
81
+ try {
82
+ raw = JSON.parse(readFileSync(path, "utf8"));
83
+ }
84
+ catch {
85
+ return {
86
+ config: DEFAULT_JUDGE_CONFIG,
87
+ problems: [`${JUDGE_CONFIG_PATH} is not JSON, so the defaults were used`],
88
+ };
89
+ }
90
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
91
+ return {
92
+ config: DEFAULT_JUDGE_CONFIG,
93
+ problems: [`${JUDGE_CONFIG_PATH} is not an object, so the defaults were used`],
94
+ };
95
+ const input = raw;
96
+ const problems = [];
97
+ const pick = (key, valid, fallback) => {
98
+ if (!(key in input))
99
+ return fallback;
100
+ if (valid(input[key]))
101
+ return input[key];
102
+ problems.push(`${key} in ${JUDGE_CONFIG_PATH} is not valid, so ${JSON.stringify(fallback)} was used`);
103
+ return fallback;
104
+ };
105
+ const whole = (min, max) => (value) => typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
106
+ const d = DEFAULT_JUDGE_CONFIG;
107
+ if (usesRetiredOffersKey(input))
108
+ problems.push(`${JUDGE_CONFIG_PATH} turns offers on with a key that is no longer read; write "offers": true instead`);
109
+ return {
110
+ config: {
111
+ schemaVersion: JUDGE_CONFIG_SCHEMA_VERSION,
112
+ mode: pick("mode", (v) => v === "report" || v === "block", d.mode),
113
+ topK: pick("topK", whole(0, 100), d.topK),
114
+ concurrency: pick("concurrency", whole(1, 16), d.concurrency),
115
+ budgetSeconds: pick("budgetSeconds", (v) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 3600, d.budgetSeconds),
116
+ sampleBelowLine: pick("sampleBelowLine", (v) => typeof v === "number" && v >= 0 && v <= 1, d.sampleBelowLine),
117
+ alwaysJudge: pick("alwaysJudge", (v) => Array.isArray(v) && v.every((id) => typeof id === "string" && PROMISE_ID.test(id)), [...d.alwaysJudge]),
118
+ client: pick("client", (v) => v === "auto" || v === "claude" || v === "codex", d.client),
119
+ select: pick("select", (v) => v === "direct" || v === "ranked", d.select),
120
+ offers: pick("offers", (v) => typeof v === "boolean", d.offers),
121
+ offersOn: pick("offersOn", (v) => v === "fixes" || v === "all", d.offersOn),
122
+ maxOffers: pick("maxOffers", whole(1, 3), d.maxOffers),
123
+ },
124
+ problems,
125
+ };
126
+ }
127
+ function runEnvironment(parent) {
128
+ // CI keeps a test runner out of watch mode, which would otherwise hold the
129
+ // command open until its time ran out.
130
+ return { ...agentEnvironment(parent), CI: "1" };
131
+ }
132
+ export async function runShell(command, cwd, options) {
133
+ const run = await runOwnedProcess({
134
+ command: "/bin/sh",
135
+ args: ["-c", command],
136
+ cwd,
137
+ env: runEnvironment(options.env),
138
+ timeoutMs: options.timeoutMs,
139
+ maxOutputBytes: 1024 * 1024,
140
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
141
+ });
142
+ return {
143
+ exitCode: run.spawnError !== undefined ? null : run.exitCode,
144
+ timedOut: run.timedOut || run.bounded,
145
+ aborted: run.aborted,
146
+ output: tail(`${run.stdout}\n${run.stderr}`, 1500),
147
+ };
148
+ }
149
+ function insideDirectory(root, cwd) {
150
+ const target = resolve(root, cwd || ".");
151
+ const inside = relative(root, target);
152
+ return inside.startsWith("..") || isAbsolute(inside) ? undefined : target;
153
+ }
154
+ /**
155
+ * Run a judge's reproduction both ways.
156
+ *
157
+ * At head, in the judge's own checkout, reset to head so a judge that left it
158
+ * on base cannot fool the check. At base, in a fresh checkout of base, with the
159
+ * files the judge added carried across so a new test exists on both sides. It
160
+ * must exit non-zero at head and zero at base; either disagreeing rejects it,
161
+ * and the reason says which.
162
+ */
163
+ export async function verifyReproduction(input) {
164
+ const timeoutMs = input.timeoutMs ?? REPRODUCTION_TIMEOUT_MS;
165
+ const seconds = Math.round(timeoutMs / 1000);
166
+ await runCommand("git", [
167
+ "-C",
168
+ input.headTree,
169
+ "checkout",
170
+ "--quiet",
171
+ "--detach",
172
+ "--force",
173
+ input.head,
174
+ ]);
175
+ const carried = await newFiles(input.headTree);
176
+ const digest = sha256Digest({ command: input.command, cwd: input.cwd, files: carried });
177
+ const reject = (failsOnHead, passesOnBase, rejected) => ({
178
+ failsOnHead,
179
+ passesOnBase,
180
+ digest,
181
+ rejected,
182
+ stopped: false,
183
+ files: carried,
184
+ });
185
+ const headCwd = insideDirectory(input.headTree, input.cwd);
186
+ if (headCwd === undefined || !existsSync(headCwd))
187
+ return reject(false, false, `its cwd ${input.cwd} is not a directory inside the repository`);
188
+ const atHead = await runShell(input.command, headCwd, {
189
+ env: input.env,
190
+ timeoutMs,
191
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
192
+ });
193
+ if (atHead.aborted)
194
+ return {
195
+ failsOnHead: false,
196
+ passesOnBase: false,
197
+ digest,
198
+ rejected: null,
199
+ stopped: true,
200
+ files: carried,
201
+ };
202
+ if (atHead.timedOut)
203
+ return reject(false, false, `it was still running at head after ${seconds} seconds`);
204
+ if (atHead.exitCode === null)
205
+ return reject(false, false, "it could not be started at head");
206
+ if (atHead.exitCode === 0)
207
+ return reject(false, false, "it passes with the change (exit 0), so it does not show a break");
208
+ const tree = await addScratchWorktree(input.root, input.base, "judge-base", input.scratch);
209
+ if (!tree.ok)
210
+ return reject(true, false, `the base commit could not be checked out: ${tree.reason}`);
211
+ try {
212
+ await shareDependencies(input.root, tree.worktree.path);
213
+ carryFiles(input.headTree, tree.worktree.path, carried);
214
+ const baseCwd = insideDirectory(tree.worktree.path, input.cwd);
215
+ if (baseCwd === undefined || !existsSync(baseCwd))
216
+ return reject(true, false, `its cwd ${input.cwd} does not exist without the change`);
217
+ const atBase = await runShell(input.command, baseCwd, {
218
+ env: input.env,
219
+ timeoutMs,
220
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
221
+ });
222
+ if (atBase.aborted)
223
+ return {
224
+ failsOnHead: true,
225
+ passesOnBase: false,
226
+ digest,
227
+ rejected: null,
228
+ stopped: true,
229
+ files: carried,
230
+ };
231
+ if (atBase.timedOut)
232
+ return reject(true, false, `it was still running at base after ${seconds} seconds`);
233
+ if (atBase.exitCode !== 0)
234
+ return reject(true, false, `it also fails without the change (exit ${atBase.exitCode ?? "none"}), so the failure is not this change's`);
235
+ return {
236
+ failsOnHead: true,
237
+ passesOnBase: true,
238
+ digest,
239
+ rejected: null,
240
+ stopped: false,
241
+ files: carried,
242
+ };
243
+ }
244
+ finally {
245
+ await tree.worktree.remove();
246
+ }
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
+ }
298
+ /**
299
+ * How this repository runs one test file, when it can be told from the files
300
+ * alone. A runner that cannot be told is `none`, and the judge is left to find
301
+ * its own way.
302
+ */
303
+ export function linkedTestCommand(tree, file) {
304
+ const quoted = `'${file.replace(/'/g, `'\\''`)}'`;
305
+ if (file.endsWith(".py"))
306
+ return `python3 -m pytest -q ${quoted}`;
307
+ if (file.endsWith(".go"))
308
+ return `go test ./${dirname(file)}`;
309
+ if (!/\.(m?[jt]sx?|cjs|cts)$/.test(file))
310
+ return undefined;
311
+ for (const [tool, run] of [
312
+ ["vitest", "run"],
313
+ ["jest", ""],
314
+ ["mocha", ""],
315
+ ]) {
316
+ if (existsSync(join(tree, "node_modules", ".bin", tool)))
317
+ return `node_modules/.bin/${tool}${run ? ` ${run}` : ""} ${quoted}`;
318
+ }
319
+ try {
320
+ if (/from\s+["']node:test["']|require\(["']node:test["']\)/.test(readFileSync(join(tree, file), "utf8")))
321
+ return `node --test ${quoted}`;
322
+ }
323
+ catch {
324
+ /* Unreadable: no command. */
325
+ }
326
+ return undefined;
327
+ }
328
+ /** The longest `nextStep` kept from a judge's answer, in characters. */
329
+ export const NEXT_STEP_LIMIT = 500;
330
+ /** The judge's final JSON, read strictly enough that a malformed answer is not a verdict. */
331
+ export function readJudgeAnswer(json) {
332
+ if (json === undefined)
333
+ return undefined;
334
+ const verdict = json.verdict;
335
+ if (verdict !== "kept" && verdict !== "broken" && verdict !== "could_not_tell")
336
+ return undefined;
337
+ const raw = json.reproduction;
338
+ const reproduction = raw !== null &&
339
+ typeof raw === "object" &&
340
+ typeof raw.command === "string" &&
341
+ raw.command.trim() !== ""
342
+ ? {
343
+ command: raw.command.trim().slice(0, 2000),
344
+ cwd: typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : ".",
345
+ }
346
+ : undefined;
347
+ const exercisedCases = Array.isArray(json.exercisedCases)
348
+ ? [
349
+ ...new Set(json.exercisedCases.filter((id) => typeof id === "string" && CASE_ID.test(id))),
350
+ ]
351
+ : [];
352
+ const nextStep = typeof json.nextStep === "string" ? json.nextStep.trim().slice(0, NEXT_STEP_LIMIT) : "";
353
+ return {
354
+ verdict,
355
+ ...(reproduction === undefined ? {} : { reproduction }),
356
+ exercisedCases,
357
+ notes: typeof json.notes === "string" ? json.notes.slice(0, 2000) : "",
358
+ ...(nextStep === "" ? {} : { nextStep }),
359
+ };
360
+ }
361
+ /**
362
+ * One promise judged against one change, with its reproduction verified.
363
+ *
364
+ * Returns `stopped` when the caller's budget ran out first, which is not a
365
+ * verdict and is never logged as one: the push is either held or told the
366
+ * judge is still running, never let through as if it had been judged.
367
+ */
368
+ export async function judgePromise(input) {
369
+ const timeoutMs = input.timeoutMs ?? JUDGE_CAP_MS;
370
+ const tree = await addScratchWorktree(input.root, input.head, `judge-${input.promise.promiseId}`, input.scratch);
371
+ if (!tree.ok)
372
+ return { status: "not_judged", reason: tree.reason };
373
+ const headTree = tree.worktree;
374
+ try {
375
+ await shareDependencies(input.root, headTree.path);
376
+ let linkedTestRun = { file: "", outcome: "none" };
377
+ const linkedTestRuns = [];
378
+ const firstTest = input.map?.linkedTests[0];
379
+ if (firstTest !== undefined) {
380
+ const command = linkedTestCommand(headTree.path, firstTest.file);
381
+ if (command === undefined)
382
+ linkedTestRun = { file: firstTest.file, outcome: "none" };
383
+ else {
384
+ const run = await runShell(command, headTree.path, {
385
+ env: input.env,
386
+ timeoutMs: Math.min(LINKED_TEST_TIMEOUT_MS, timeoutMs),
387
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
388
+ });
389
+ if (run.aborted)
390
+ return { status: "stopped" };
391
+ const outcome = run.timedOut || run.exitCode === null || run.exitCode === 127
392
+ ? "error"
393
+ : run.exitCode === 0
394
+ ? "pass"
395
+ : "fail";
396
+ linkedTestRun = { file: firstTest.file, outcome };
397
+ linkedTestRuns.push({
398
+ file: firstTest.file,
399
+ command,
400
+ outcome,
401
+ output: tail(run.output, 800),
402
+ });
403
+ }
404
+ }
405
+ const agent = await (input.runAgent ?? runHeadlessAgent)({
406
+ client: input.login.client,
407
+ login: input.login,
408
+ cwd: headTree.path,
409
+ prompt: judgePrompt({
410
+ promise: input.promise,
411
+ ...(input.map === undefined ? {} : { map: input.map }),
412
+ changedFiles: input.changedFiles,
413
+ base: input.base,
414
+ head: input.head,
415
+ linkedTestRuns,
416
+ minutes: Math.max(1, Math.floor(timeoutMs / 60_000)),
417
+ }),
418
+ allowedTools: AGENT_TOOLS,
419
+ disallowedTools: REFUSED_TOOL_RULES,
420
+ sandbox: "workspace-write",
421
+ timeoutMs,
422
+ env: input.env,
423
+ jsonOutput: true,
424
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
425
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
426
+ });
427
+ if (agent.aborted)
428
+ return { status: "stopped" };
429
+ if (agent.reason === "no_verified_login")
430
+ return { status: "not_judged", reason: agentFailure(agent) };
431
+ const judge = {
432
+ client: input.login.client,
433
+ model: agent.model ?? "",
434
+ durationMs: agent.durationMs,
435
+ inputTokens: agent.inputTokens ?? 0,
436
+ outputTokens: agent.outputTokens ?? 0,
437
+ };
438
+ const verdict = (fields) => ({
439
+ schemaVersion: JUDGE_VERDICT_SCHEMA_VERSION,
440
+ promiseId: input.promise.promiseId,
441
+ semanticDigest: input.promise.semanticDigest,
442
+ base: input.base,
443
+ head: input.head,
444
+ verdict: "could_not_tell",
445
+ reproduction: null,
446
+ reproductionRejected: null,
447
+ exercisedCases: [],
448
+ notes: "",
449
+ judge,
450
+ briefRevision: JUDGE_BRIEF_REVISION,
451
+ mapUsed: input.map !== undefined,
452
+ linkedTestRun,
453
+ ...fields,
454
+ });
455
+ const answer = agent.ok ? readJudgeAnswer(agent.json) : undefined;
456
+ if (answer === undefined)
457
+ return {
458
+ status: "judged",
459
+ verdict: verdict({
460
+ notes: agent.ok ? "the judge's answer was not a verdict" : agentFailure(agent),
461
+ }),
462
+ };
463
+ if (answer.verdict === "kept") {
464
+ const missing = caseIds(input.promise).passing.filter((id) => !answer.exercisedCases.includes(id));
465
+ if (missing.length > 0)
466
+ return {
467
+ status: "judged",
468
+ verdict: verdict({
469
+ exercisedCases: answer.exercisedCases,
470
+ notes: `The judge answered kept without exercising ${missing.join(", ")}. ${answer.notes}`.trim(),
471
+ }),
472
+ };
473
+ return {
474
+ status: "judged",
475
+ verdict: verdict({
476
+ verdict: "kept",
477
+ exercisedCases: answer.exercisedCases,
478
+ notes: answer.notes,
479
+ }),
480
+ };
481
+ }
482
+ if (answer.verdict === "could_not_tell" || answer.reproduction === undefined)
483
+ return {
484
+ status: "judged",
485
+ verdict: verdict({
486
+ exercisedCases: answer.exercisedCases,
487
+ notes: answer.verdict === "broken"
488
+ ? `The judge answered broken without a command that shows it. ${answer.notes}`.trim()
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
+ : {}),
494
+ }),
495
+ };
496
+ const check = await verifyReproduction({
497
+ root: input.root,
498
+ headTree: headTree.path,
499
+ head: input.head,
500
+ base: input.base,
501
+ command: answer.reproduction.command,
502
+ cwd: answer.reproduction.cwd,
503
+ env: input.env,
504
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
505
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
506
+ });
507
+ if (check.stopped)
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;
525
+ const reproduction = {
526
+ command: answer.reproduction.command,
527
+ cwd: answer.reproduction.cwd,
528
+ failsOnHead: check.failsOnHead,
529
+ passesOnBase: check.passesOnBase,
530
+ digest: check.digest,
531
+ ...(kept === undefined ? {} : { keptIn: kept.keptIn, keptFiles: kept.files }),
532
+ };
533
+ return {
534
+ status: "judged",
535
+ verdict: verdict({
536
+ verdict: check.rejected === null ? "broken" : "could_not_tell",
537
+ reproduction,
538
+ reproductionRejected: check.rejected,
539
+ exercisedCases: answer.exercisedCases,
540
+ notes: answer.notes,
541
+ }),
542
+ };
543
+ }
544
+ finally {
545
+ await headTree.remove();
546
+ }
547
+ }
548
+ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--top <k>] [--base <ref>] [--head <ref>]
549
+ [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]
550
+ [--from-file <promise.json> [--map <map.json>]]
551
+ [--hook claude|codex|git] [--install-hook [claude|codex|git]]
552
+ [--push-check on|off]
553
+ Judge whether this change keeps the promises it could break, one promise
554
+ at a time, on your own Claude Code or Codex login. A break counts only
555
+ with a command that fails with the change and passes without it, and
556
+ this command runs it both ways itself; anything unproven is "could not
557
+ tell". Report mode prints one line per promise and never stops anything;
558
+ --block exits 2 on a reproduced break, and when judges are still running
559
+ at the time limit it holds the push rather than letting it through.
560
+ --wait runs without the time limit. Promises come from --promises, else
561
+ from \`balladeer risk\`: every promise whose own code or tests the
562
+ change edits, or with "select": "ranked" in .continuity/judge.json the
563
+ top of its ranking and a small random sample; else every mapped
564
+ promise; plus that file's alwaysJudge. Verdicts are kept in
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.
579
+ `;
580
+ export function parseJudgeArguments(argv) {
581
+ const args = [...argv];
582
+ let promises;
583
+ let topK;
584
+ let base;
585
+ let head;
586
+ let json = false;
587
+ let mode;
588
+ let wait = false;
589
+ let hook;
590
+ let userScope = false;
591
+ let pushCheck;
592
+ let installHook;
593
+ let fromFile;
594
+ let map;
595
+ let repo;
596
+ let client;
597
+ let repository;
598
+ let controlPlane;
599
+ const value = (flag, inline) => {
600
+ const next = inline ?? args.shift();
601
+ if (next === undefined || next === "")
602
+ throw new StoreError("usage", `${flag} needs a value.`);
603
+ return next;
604
+ };
605
+ const host = (flag, requested) => {
606
+ if (requested !== "claude" && requested !== "codex" && requested !== "git")
607
+ throw new StoreError("usage", `${flag} needs claude, codex or git.`);
608
+ return requested;
609
+ };
610
+ while (args.length > 0) {
611
+ const arg = args.shift();
612
+ if (!arg.startsWith("--"))
613
+ throw new StoreError("usage", `judge takes no bare arguments: ${arg}.`);
614
+ const [name, ...rest] = arg.split("=");
615
+ const inline = rest.length > 0 ? rest.join("=") : undefined;
616
+ if (name === "--promises") {
617
+ promises = value(name, inline)
618
+ .split(",")
619
+ .map((id) => id.trim())
620
+ .filter(Boolean);
621
+ const bad = promises.find((id) => !PROMISE_ID.test(id));
622
+ if (bad !== undefined)
623
+ throw new StoreError("usage", `"${bad}" is not a promise id.`);
624
+ }
625
+ else if (name === "--top") {
626
+ const k = Number(value(name, inline));
627
+ if (!Number.isInteger(k) || k < 0 || k > 100)
628
+ throw new StoreError("usage", "--top needs a whole number from 0 to 100.");
629
+ topK = k;
630
+ }
631
+ else if (name === "--base")
632
+ base = value(name, inline);
633
+ else if (name === "--head")
634
+ head = value(name, inline);
635
+ else if (name === "--json")
636
+ json = true;
637
+ else if (name === "--report-only")
638
+ mode = "report";
639
+ else if (name === "--block")
640
+ mode = "block";
641
+ else if (name === "--wait")
642
+ wait = true;
643
+ else if (name === "--hook")
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
+ }
653
+ else if (name === "--install-hook") {
654
+ const next = inline ?? (["claude", "codex", "git"].includes(args[0] ?? "") ? args.shift() : undefined);
655
+ installHook = next === undefined ? "agents" : host(name, next);
656
+ }
657
+ else if (name === "--from-file")
658
+ fromFile = value(name, inline);
659
+ else if (name === "--map")
660
+ map = value(name, inline);
661
+ else if (name === "--repo")
662
+ repo = value(name, inline);
663
+ else if (name === "--repository")
664
+ repository = value(name, inline);
665
+ else if (name === "--control-plane")
666
+ controlPlane = value(name, inline);
667
+ else if (name === "--client") {
668
+ const requested = value(name, inline);
669
+ if (requested !== "auto" && requested !== "claude" && requested !== "codex")
670
+ throw new StoreError("usage", "--client needs auto, claude or codex.");
671
+ client = requested;
672
+ }
673
+ else
674
+ throw new StoreError("usage", `Unknown option ${arg} for judge.`);
675
+ }
676
+ if (map !== undefined && fromFile === undefined)
677
+ throw new StoreError("usage", "--map goes with --from-file; a mapped promise's own map is read by itself.");
678
+ if (fromFile !== undefined && promises !== undefined)
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.");
682
+ const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
683
+ return {
684
+ ...(promises === undefined ? {} : { promises }),
685
+ ...(topK === undefined ? {} : { topK }),
686
+ ...(base === undefined ? {} : { base }),
687
+ ...(head === undefined ? {} : { head }),
688
+ json,
689
+ ...(mode === undefined ? {} : { mode }),
690
+ wait,
691
+ ...(hook === undefined ? {} : { hook }),
692
+ ...(userScope ? { userScope: true } : {}),
693
+ ...(pushCheck === undefined ? {} : { pushCheck }),
694
+ ...(installHook === undefined ? {} : { installHook }),
695
+ ...(fromFile === undefined ? {} : { fromFile }),
696
+ ...(map === undefined ? {} : { map }),
697
+ ...(repo === undefined ? {} : { repo }),
698
+ ...(client === undefined ? {} : { client }),
699
+ ...(repository === undefined ? {} : { repository }),
700
+ controlPlane: normalizeControlPlane(chosen),
701
+ };
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
+ }
748
+ /** Read a risk report out of whatever `balladeer risk --json` printed. */
749
+ export function readRiskOutput(stdout) {
750
+ const documents = [];
751
+ try {
752
+ documents.push(JSON.parse(stdout));
753
+ }
754
+ catch {
755
+ for (const line of stdout.split("\n")) {
756
+ try {
757
+ documents.push(JSON.parse(line));
758
+ }
759
+ catch {
760
+ /* Not a JSON line. */
761
+ }
762
+ }
763
+ }
764
+ for (const document of documents) {
765
+ const report = document?.schemaVersion === "balladeer-risk/v1"
766
+ ? document
767
+ : document?.report;
768
+ if (report?.schemaVersion !== "balladeer-risk/v1")
769
+ continue;
770
+ const rows = report.promises;
771
+ if (!Array.isArray(rows))
772
+ continue;
773
+ return rows.flatMap((row) => {
774
+ const entry = row;
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
+ ];
792
+ });
793
+ }
794
+ return undefined;
795
+ }
796
+ /** `balladeer risk --json`, run as a child of this same command, when this build has it. */
797
+ async function riskFromCommand(root, base, env) {
798
+ const entry = process.argv[1];
799
+ if (entry === undefined || !/(^|[/\\])(cli\.(m?js|ts)|balladeer)$/.test(entry))
800
+ return undefined;
801
+ const run = await runOwnedProcess({
802
+ command: process.execPath,
803
+ args: [...process.execArgv, entry, "risk", "--json", "--base", base],
804
+ cwd: root,
805
+ env: { ...env, [JUDGE_ACTIVE_VARIABLE]: "1" },
806
+ timeoutMs: 60_000,
807
+ maxOutputBytes: 4 * 1024 * 1024,
808
+ });
809
+ return run.exitCode === 0 ? readRiskOutput(run.stdout) : undefined;
810
+ }
811
+ /**
812
+ * The commit a change is measured from: the fork point with the remote default
813
+ * branch, else with this branch's upstream, else the parent of head.
814
+ */
815
+ export async function defaultBase(root, head) {
816
+ const branches = [];
817
+ const remoteHead = await runCommand("git", [
818
+ "-C",
819
+ root,
820
+ "rev-parse",
821
+ "--abbrev-ref",
822
+ "origin/HEAD",
823
+ ]);
824
+ if (remoteHead.ok && remoteHead.stdout.trim() && remoteHead.stdout.trim() !== "origin/HEAD")
825
+ branches.push(remoteHead.stdout.trim());
826
+ branches.push("origin/main", "origin/master", "@{upstream}");
827
+ for (const branch of branches) {
828
+ const ref = await resolveCommit(root, branch);
829
+ if (ref === undefined)
830
+ continue;
831
+ const forkPoint = await runCommand("git", ["-C", root, "merge-base", head, ref]);
832
+ const sha = forkPoint.stdout.trim();
833
+ if (forkPoint.ok && /^[0-9a-f]{40}$/.test(sha) && sha !== head)
834
+ return sha;
835
+ }
836
+ return resolveCommit(root, `${head}^`);
837
+ }
838
+ export async function changedFiles(root, base, head) {
839
+ const diff = await runCommand("git", ["-C", root, "diff", "--name-only", base, head], {
840
+ timeoutMs: 60_000,
841
+ });
842
+ return diff.ok ? diff.stdout.split("\n").filter((line) => line.trim() !== "") : [];
843
+ }
844
+ /**
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.
859
+ */
860
+ export function selectPromises(input) {
861
+ const out = [];
862
+ const seen = new Set();
863
+ const add = (promiseId, why) => {
864
+ if (seen.has(promiseId))
865
+ return;
866
+ seen.add(promiseId);
867
+ out.push({ promiseId, why });
868
+ };
869
+ if (input.named !== undefined)
870
+ for (const id of input.named)
871
+ add(id, "named");
872
+ else if (input.risk !== undefined) {
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
+ }
890
+ }
891
+ else
892
+ for (const id of input.mapped)
893
+ add(id, "mapped");
894
+ for (const id of input.config.alwaysJudge)
895
+ add(id, "always");
896
+ return out;
897
+ }
898
+ function logPath(root, now) {
899
+ // One file per UTC day. A file name, not a sentence: nobody reads it as a time.
900
+ const day = now.toISOString().slice(0, 10);
901
+ return join(root, JUDGE_LOG_DIRECTORY, `${day}.jsonl`);
902
+ }
903
+ /**
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.
907
+ */
908
+ function judgeLogDirectory(root) {
909
+ const directory = join(root, JUDGE_LOG_DIRECTORY);
910
+ mkdirSync(directory, { recursive: true });
911
+ const ignore = join(directory, ".gitignore");
912
+ if (!existsSync(ignore))
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);
924
+ appendFileSync(logPath(root, now), verdicts.map((verdict) => `${JSON.stringify(verdict)}\n`).join(""), "utf8");
925
+ }
926
+ /** A verdict already reached for exactly this promise, meaning, change and brief. */
927
+ export function findLoggedVerdict(root, key, now) {
928
+ const days = [now, new Date(now.getTime() - 86_400_000)];
929
+ for (const day of days) {
930
+ const path = logPath(root, day);
931
+ if (!existsSync(path))
932
+ continue;
933
+ const lines = readFileSync(path, "utf8").split("\n").reverse();
934
+ for (const line of lines) {
935
+ if (!line.trim())
936
+ continue;
937
+ try {
938
+ const verdict = JSON.parse(line);
939
+ if (verdict.schemaVersion === JUDGE_VERDICT_SCHEMA_VERSION &&
940
+ verdict.promiseId === key.promiseId &&
941
+ verdict.semanticDigest === key.semanticDigest &&
942
+ verdict.base === key.base &&
943
+ verdict.head === key.head &&
944
+ verdict.briefRevision === JUDGE_BRIEF_REVISION)
945
+ return verdict;
946
+ }
947
+ catch {
948
+ /* A torn line is skipped. */
949
+ }
950
+ }
951
+ }
952
+ return undefined;
953
+ }
954
+ function sentence(text, limit = 240) {
955
+ const flat = text.replace(/\s+/g, " ").trim();
956
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
957
+ }
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 = {}) {
1024
+ const name = `${title} (${verdict.promiseId})`;
1025
+ const earlier = context.reused ? " [judged earlier for this commit]" : "";
1026
+ if (verdict.verdict === "kept")
1027
+ return `kept: ${name}${earlier}`;
1028
+ if (verdict.verdict === "broken") {
1029
+ const where = verdict.reproduction && verdict.reproduction.cwd !== "."
1030
+ ? ` (in ${verdict.reproduction.cwd})`
1031
+ : "";
1032
+ return (`broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}\n` +
1033
+ ` ${afterBreakLine(verdict, context.checkBound)}`);
1034
+ }
1035
+ const rejected = verdict.reproductionRejected
1036
+ ? ` A reported break did not hold up when run both ways: ${verdict.reproductionRejected}.`
1037
+ : "";
1038
+ const notes = verdict.notes ? ` ${sentence(verdict.notes)}` : "";
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;
1081
+ }
1082
+ async function readStdin(limit = 1024 * 1024, idleMs = 5000) {
1083
+ if (process.stdin.isTTY)
1084
+ return "";
1085
+ return new Promise((resolveText) => {
1086
+ const chunks = [];
1087
+ let size = 0;
1088
+ let timer = setTimeout(done, idleMs);
1089
+ function done() {
1090
+ clearTimeout(timer);
1091
+ process.stdin.removeAllListeners("data");
1092
+ process.stdin.removeAllListeners("end");
1093
+ process.stdin.pause();
1094
+ resolveText(Buffer.concat(chunks).toString("utf8"));
1095
+ }
1096
+ process.stdin.on("data", (chunk) => {
1097
+ size += chunk.length;
1098
+ if (size <= limit)
1099
+ chunks.push(chunk);
1100
+ clearTimeout(timer);
1101
+ timer = setTimeout(done, idleMs);
1102
+ });
1103
+ process.stdin.on("end", done);
1104
+ process.stdin.resume();
1105
+ });
1106
+ }
1107
+ function describeInstall(result) {
1108
+ if (result.status === "written")
1109
+ return `Written: ${result.path}.`;
1110
+ if (result.status === "unchanged")
1111
+ return `Already current: ${result.path}. Nothing was changed.`;
1112
+ return `Not changed: ${result.path}. ${result.reason ?? ""}`.trim();
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
+ }
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
+ }
1148
+ export async function runJudge(options) {
1149
+ const { args } = options;
1150
+ const deps = options.deps ?? {};
1151
+ const now = deps.now ?? (() => new Date());
1152
+ const hook = args.hook;
1153
+ const json = args.json && hook === undefined;
1154
+ const lines = [];
1155
+ const emit = (step) => {
1156
+ if (json)
1157
+ options.write(`${JSON.stringify(step)}\n`);
1158
+ };
1159
+ // Started once the change is known, when the team turned offers on: the
1160
+ // rules this change establishes that no promise records. It spends from the
1161
+ // hook's own budget and is awaited only at the moment of answering, so it
1162
+ // never delays a judge; whatever it does, it never changes the answer's
1163
+ // exit, and a reading still running at the deadline is stopped and says
1164
+ // nothing.
1165
+ let offerReading;
1166
+ let offerDeadline = 0;
1167
+ const offerStop = new AbortController();
1168
+ const settleOffers = async () => {
1169
+ if (offerReading === undefined)
1170
+ return [];
1171
+ let timer;
1172
+ const late = new Promise((done) => {
1173
+ timer = setTimeout(() => {
1174
+ offerStop.abort();
1175
+ done([]);
1176
+ }, Math.max(0, offerDeadline - Date.now()));
1177
+ });
1178
+ try {
1179
+ return await Promise.race([offerReading.catch(() => []), late]);
1180
+ }
1181
+ finally {
1182
+ if (timer !== undefined)
1183
+ clearTimeout(timer);
1184
+ }
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;
1197
+ const respond = async (block) => {
1198
+ if (commitFirst !== undefined)
1199
+ lines.unshift(commitFirstLine(commitFirst));
1200
+ commitFirst = undefined;
1201
+ const offered = await settleOffers();
1202
+ lines.push(...trailing);
1203
+ // Apart from the verdicts, after them, with a line between.
1204
+ if (offered.length > 0)
1205
+ lines.push(...(lines.length > 0 ? [""] : []), ...offered);
1206
+ const response = hookResponse(hook ?? "git", { block, lines, event });
1207
+ if (!json) {
1208
+ if (response.stdout)
1209
+ options.write(response.stdout);
1210
+ if (response.stderr && (hook !== undefined || block))
1211
+ options.error(response.stderr);
1212
+ }
1213
+ return response.exitCode;
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
+ }
1220
+ // A judge's own shell commands run with this set, and a push hook inside a
1221
+ // judge would otherwise start judging all over again.
1222
+ if (hook !== undefined && options.environment[JUDGE_ACTIVE_VARIABLE] === "1")
1223
+ return 0;
1224
+ let cwd = options.cwd;
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;
1235
+ if (hook !== undefined) {
1236
+ const input = await (deps.stdin ?? (() => readStdin()))();
1237
+ if (hook === "git") {
1238
+ const refs = parsePrePushInput(input);
1239
+ if (refs.length === 0)
1240
+ return 0;
1241
+ pushedHead = refs[0].localSha;
1242
+ }
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");
1248
+ const parsed = parseHookInput(input);
1249
+ if (parsed?.command === undefined || !isPushCommand(parsed.command))
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;
1258
+ if (parsed.cwd !== undefined && existsSync(parsed.cwd))
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);
1283
+ }
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
+ };
1296
+ const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(cwd);
1297
+ if (root === undefined || !existsSync(root)) {
1298
+ if (hook !== undefined)
1299
+ return place?.known === false ? pushedFromUnknown() : 0;
1300
+ const message = "This is not a git repository, so there is no change to judge.";
1301
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
1302
+ if (!json)
1303
+ options.error(`${message}\n`);
1304
+ return 4;
1305
+ }
1306
+ if (args.installHook !== undefined) {
1307
+ const targets = args.installHook === "agents" ? ["claude", "codex"] : [args.installHook];
1308
+ let refused = false;
1309
+ for (const target of targets) {
1310
+ const result = installJudgeHook(root, target);
1311
+ refused ||= result.status === "refused";
1312
+ emit({ step: "hook", path: result.path, changed: result.status === "written" });
1313
+ if (!json)
1314
+ options.write(`${describeInstall(result)}\n`);
1315
+ }
1316
+ if (!json) {
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');
1318
+ if (targets.includes("codex"))
1319
+ options.write("Codex runs a project hook once you trust it: open /hooks in Codex and review it.\n");
1320
+ }
1321
+ return refused ? 4 : 0;
1322
+ }
1323
+ const { config, problems } = readJudgeConfig(root);
1324
+ const mode = args.mode ?? config.mode;
1325
+ for (const problem of problems)
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
+ }
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
+ }
1389
+ const head = await resolveCommit(root, args.head ?? pushedHead ?? "HEAD");
1390
+ if (head === undefined) {
1391
+ const message = `${args.head ?? "HEAD"} is not a commit here, so there is nothing to judge.`;
1392
+ if (hook !== undefined)
1393
+ return 0;
1394
+ emit({ step: "error", reason: "no_head", message, changed: false, exitCode: 4 });
1395
+ if (!json)
1396
+ options.error(`${message}\n`);
1397
+ return 4;
1398
+ }
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)));
1423
+ if (base === undefined || base === head) {
1424
+ const message = base === undefined
1425
+ ? `${args.base ?? "the base"} is not a commit here, so nothing was judged.`
1426
+ : "Nothing to judge: the change is empty.";
1427
+ emit({
1428
+ step: "judge",
1429
+ status: "nothing_to_judge",
1430
+ reason: base === undefined ? "no_base" : "empty_change",
1431
+ message,
1432
+ exitCode: 0,
1433
+ });
1434
+ if (hook === undefined)
1435
+ lines.push(message);
1436
+ return respond(false);
1437
+ }
1438
+ const files = await changedFiles(root, base, head);
1439
+ // Which promises, before spending anything on a login check.
1440
+ let filePromise;
1441
+ let fileMap;
1442
+ if (args.fromFile !== undefined) {
1443
+ const read = readPromiseFile(resolve(options.cwd, args.fromFile));
1444
+ if (!read.ok) {
1445
+ emit({
1446
+ step: "error",
1447
+ reason: "promise_unreadable",
1448
+ message: read.reason,
1449
+ changed: false,
1450
+ exitCode: 4,
1451
+ });
1452
+ if (!json)
1453
+ options.error(`${read.reason}\n`);
1454
+ return 4;
1455
+ }
1456
+ filePromise = read.promise;
1457
+ if (args.map !== undefined) {
1458
+ try {
1459
+ const parsed = parseBehaviorMap(JSON.parse(readFileSync(resolve(options.cwd, args.map), "utf8")));
1460
+ if (!parsed.ok)
1461
+ throw new Error(parsed.errors.slice(0, 3).join("; "));
1462
+ fileMap = parsed.value;
1463
+ }
1464
+ catch (error) {
1465
+ const message = `${args.map} is not a usable behavior map: ${error instanceof Error ? error.message : String(error)}`;
1466
+ emit({ step: "error", reason: "map_unreadable", message, changed: false, exitCode: 4 });
1467
+ if (!json)
1468
+ options.error(`${message}\n`);
1469
+ return 4;
1470
+ }
1471
+ }
1472
+ }
1473
+ if (config.offers && hook !== undefined) {
1474
+ // Even when no promise is at risk and no map exists: a change nobody
1475
+ // mapped is exactly where a rule nobody wrote down is likeliest.
1476
+ offerDeadline = Date.now() + effectiveBudgetSeconds(config.budgetSeconds, true) * 1000;
1477
+ offerReading = offersForPush({
1478
+ root,
1479
+ base,
1480
+ head,
1481
+ files,
1482
+ host: hook,
1483
+ environment: options.environment,
1484
+ controlPlane: args.controlPlane,
1485
+ client: args.client ?? config.client,
1486
+ deadline: offerDeadline,
1487
+ signal: offerStop.signal,
1488
+ policy: config.offersOn,
1489
+ max: config.maxOffers,
1490
+ ...(deps.reader === undefined ? {} : { reader: deps.reader }),
1491
+ ...(deps.offerAgent === undefined ? {} : { runAgent: deps.offerAgent }),
1492
+ });
1493
+ }
1494
+ const mappedIds = readBehaviorMaps(join(root, ".continuity/behavior-maps"))
1495
+ .filter((entry) => entry.map !== undefined)
1496
+ .map((entry) => entry.promiseId);
1497
+ const named = filePromise !== undefined ? [filePromise.promiseId] : args.promises;
1498
+ const risk = named === undefined
1499
+ ? await (deps.riskReport ?? ((r, b) => riskFromCommand(r, b, options.environment)))(root, base, head)
1500
+ : undefined;
1501
+ const selected = selectPromises({
1502
+ ...(named === undefined ? {} : { named }),
1503
+ ...(risk === undefined ? {} : { risk }),
1504
+ mapped: mappedIds,
1505
+ // A promise handed over in a file is judged alone: the repository's own
1506
+ // standing list belongs to its own promises, not to the one in the file.
1507
+ config: filePromise === undefined ? config : { ...config, alwaysJudge: [] },
1508
+ ...(args.topK === undefined ? {} : { topK: args.topK }),
1509
+ random: deps.random ?? Math.random,
1510
+ });
1511
+ if (selected.length === 0) {
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
+ });
1524
+ lines.push(message);
1525
+ return respond(false);
1526
+ }
1527
+ const prefer = hook === "claude" || hook === "codex" ? hook : undefined;
1528
+ const requested = args.client ?? config.client;
1529
+ const login = await (deps.resolveLogin ??
1530
+ ((client, preferred) => resolveClient(client, {
1531
+ env: options.environment,
1532
+ cwd: root,
1533
+ ...(preferred === undefined ? {} : { prefer: preferred }),
1534
+ })))(requested, prefer);
1535
+ if (login === undefined) {
1536
+ const message = "not judged: no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine, so Balladeer did not judge this change.";
1537
+ emit({ step: "judge", status: "not_judged", reason: "no_login", message, exitCode: 0 });
1538
+ lines.push(message);
1539
+ return respond(false);
1540
+ }
1541
+ // Meanings: the file, else this repository's connection, else the map's own excerpt.
1542
+ let reader = deps.reader ?? undefined;
1543
+ if (deps.reader === undefined && filePromise === undefined) {
1544
+ const connection = connectionReader({
1545
+ environment: options.environment,
1546
+ controlPlane: args.controlPlane,
1547
+ cwd: root,
1548
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1549
+ });
1550
+ if (connection.ok)
1551
+ reader = connection.reader;
1552
+ }
1553
+ const jobs = [];
1554
+ const unreadable = [];
1555
+ for (const { promiseId } of selected) {
1556
+ const map = filePromise !== undefined ? fileMap : readBehaviorMap(root, promiseId);
1557
+ let promise = filePromise;
1558
+ if (promise === undefined && reader !== undefined) {
1559
+ const read = await reader.get(promiseId);
1560
+ if (read.ok)
1561
+ promise = read.promise;
1562
+ }
1563
+ if (promise === undefined && map?.meaning !== undefined)
1564
+ promise = promiseFromExcerpt(promiseId, map.semanticDigest, map.meaning);
1565
+ if (promise === undefined)
1566
+ unreadable.push(promiseId);
1567
+ else
1568
+ jobs.push({ promise, ...(map === undefined ? {} : { map }) });
1569
+ }
1570
+ const stopHandler = () => {
1571
+ removeLiveWorktreesNow();
1572
+ process.exit(130);
1573
+ };
1574
+ process.once("SIGINT", stopHandler);
1575
+ process.once("SIGTERM", stopHandler);
1576
+ const budget = new AbortController();
1577
+ // From a hook, the host's own timeout rules and a timed-out hook lets the
1578
+ // push run, so the budget is capped below it; by hand, the person's number.
1579
+ const budgetMs = args.wait
1580
+ ? undefined
1581
+ : effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000;
1582
+ const timer = budgetMs === undefined ? undefined : setTimeout(() => budget.abort(), budgetMs);
1583
+ const started = Date.now();
1584
+ const verdicts = [];
1585
+ const stillJudging = [];
1586
+ const notJudged = [];
1587
+ try {
1588
+ const queue = [...jobs];
1589
+ const worker = async () => {
1590
+ for (;;) {
1591
+ const job = queue.shift();
1592
+ if (job === undefined)
1593
+ return;
1594
+ const logged = findLoggedVerdict(root, {
1595
+ promiseId: job.promise.promiseId,
1596
+ semanticDigest: job.promise.semanticDigest,
1597
+ base,
1598
+ head,
1599
+ }, now());
1600
+ if (logged !== undefined) {
1601
+ verdicts.push({
1602
+ verdict: logged,
1603
+ title: job.promise.title,
1604
+ reused: true,
1605
+ checkBound: job.promise.verifierBound,
1606
+ });
1607
+ continue;
1608
+ }
1609
+ if (budget.signal.aborted) {
1610
+ stillJudging.push(job);
1611
+ continue;
1612
+ }
1613
+ const remaining = budgetMs === undefined ? JUDGE_CAP_MS : Math.max(1000, budgetMs - (Date.now() - started));
1614
+ const outcome = await (deps.judge ?? judgePromise)({
1615
+ root,
1616
+ promise: job.promise,
1617
+ ...(job.map === undefined ? {} : { map: job.map }),
1618
+ base,
1619
+ head,
1620
+ changedFiles: files,
1621
+ login,
1622
+ env: options.environment,
1623
+ timeoutMs: Math.min(JUDGE_CAP_MS, remaining),
1624
+ signal: budget.signal,
1625
+ ...(deps.scratch === undefined ? {} : { scratch: deps.scratch }),
1626
+ ...(deps.runAgent === undefined ? {} : { runAgent: deps.runAgent }),
1627
+ });
1628
+ if (outcome.status === "stopped")
1629
+ stillJudging.push(job);
1630
+ else if (outcome.status === "not_judged")
1631
+ notJudged.push({ id: job.promise.promiseId, reason: outcome.reason });
1632
+ else {
1633
+ verdicts.push({
1634
+ verdict: outcome.verdict,
1635
+ title: job.promise.title,
1636
+ reused: false,
1637
+ checkBound: job.promise.verifierBound,
1638
+ });
1639
+ appendVerdicts(root, [outcome.verdict], now());
1640
+ }
1641
+ }
1642
+ };
1643
+ await Promise.all(Array.from({ length: Math.min(config.concurrency, Math.max(1, jobs.length)) }, worker));
1644
+ }
1645
+ finally {
1646
+ if (timer !== undefined)
1647
+ clearTimeout(timer);
1648
+ process.removeListener("SIGINT", stopHandler);
1649
+ process.removeListener("SIGTERM", stopHandler);
1650
+ }
1651
+ const order = new Map(selected.map((entry, index) => [entry.promiseId, index]));
1652
+ verdicts.sort((a, b) => (order.get(a.verdict.promiseId) ?? 0) - (order.get(b.verdict.promiseId) ?? 0));
1653
+ const broken = verdicts.filter((entry) => entry.verdict.verdict === "broken");
1654
+ if (json)
1655
+ for (const { verdict } of verdicts)
1656
+ options.write(`${JSON.stringify(verdict)}\n`);
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:`);
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();
1667
+ for (const { id, reason } of notJudged)
1668
+ lines.push(` not judged: ${id}: ${reason}`);
1669
+ for (const id of unreadable)
1670
+ lines.push(` not judged: ${id}: its meaning could not be read here and it has no behavior map.`);
1671
+ if (stillJudging.length > 0) {
1672
+ const ids = stillJudging.map((job) => job.promise.promiseId);
1673
+ const names = stillJudging
1674
+ .map((job) => `${job.promise.title} (${job.promise.promiseId})`)
1675
+ .join("; ");
1676
+ const message = mode === "block"
1677
+ ? `Held: ${STILL_JUDGING}, then push again. Still judging when the time ran out: ${names}.`
1678
+ : `Still judging when the time ran out, so not judged yet: ${names}. Run \`balladeer judge --wait\` to finish them.`;
1679
+ lines.push(message);
1680
+ emit({
1681
+ step: "judge",
1682
+ status: "still_judging",
1683
+ reason: "budget_spent",
1684
+ message,
1685
+ promiseIds: ids,
1686
+ exitCode: mode === "block" ? 2 : 0,
1687
+ });
1688
+ }
1689
+ if (broken.length > 0 && mode === "block")
1690
+ lines.push("This push is stopped because a promise's break was reproduced both ways. Fix the behavior, or run the reproduction to see it, then push again.");
1691
+ const block = mode === "block" && (broken.length > 0 || stillJudging.length > 0);
1692
+ if (json)
1693
+ return block ? 2 : 0;
1694
+ return respond(block);
1695
+ }
1696
+ /** `balladeer judge ...` from raw arguments, as `cli.ts` hands them over. */
1697
+ export async function judgeCommand(argv, io) {
1698
+ let args;
1699
+ try {
1700
+ args = parseJudgeArguments(argv);
1701
+ }
1702
+ catch (error) {
1703
+ io.error(`${error instanceof Error ? error.message : String(error)}\n\n${JUDGE_USAGE}`);
1704
+ return 4;
1705
+ }
1706
+ return runJudge({
1707
+ args,
1708
+ cwd: io.cwd,
1709
+ environment: io.environment,
1710
+ write: io.write,
1711
+ error: io.error,
1712
+ });
1713
+ }