balladeer 1.0.14 → 1.0.16

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 +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,1170 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, 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 { effectiveBudgetSeconds, hookResponse, installJudgeHook, isPushCommand, parseHookInput, parsePrePushInput, } from "../judge-hook.js";
9
+ import { runOwnedProcess, tail } from "../owned-process.js";
10
+ import { caseIds, connectionReader, promiseFromExcerpt, readPromiseFile, sha256Digest, } from "../promise-meaning.js";
11
+ import { addScratchWorktree, carryFiles, newFiles, removeLiveWorktreesNow, resolveCommit, shareDependencies, } from "../scratch-worktree.js";
12
+ import { normalizeControlPlane, StoreError } from "../store.js";
13
+ import { CLI_INVOCATION, DEFAULT_CONTROL_PLANE } from "../wire.js";
14
+ import { AGENT_TOOLS, REFUSED_TOOL_RULES, agentFailure, readBehaviorMap, readBehaviorMaps, } from "./map.js";
15
+ import { offersForPush } from "./offers.js";
16
+ /**
17
+ * `balladeer judge`: for a change about to leave this machine, ask the person's
18
+ * own coding agent, one promise at a time, whether the change keeps it.
19
+ *
20
+ * A verdict is only as good as its evidence, so the runner does not take a
21
+ * judge's word for a break. A judge that says "broken" must hand back a command,
22
+ * and the runner runs that command itself, once at head and once in a clean
23
+ * checkout of base: it must fail with the change and pass without it. Anything
24
+ * short of that is "could not tell", with the reason kept. Nothing here ever
25
+ * says a promise is protected; a judge's "kept" is a local reading of one
26
+ * change, not a check that holds.
27
+ */
28
+ export const JUDGE_VERDICT_SCHEMA_VERSION = "balladeer-judge-verdict/v1";
29
+ export const JUDGE_CONFIG_SCHEMA_VERSION = "balladeer-judge-config/v1";
30
+ export const JUDGE_CONFIG_PATH = ".continuity/judge.json";
31
+ export const JUDGE_LOG_DIRECTORY = ".continuity/judge-log";
32
+ export const STILL_JUDGING = "still judging; rerun `balladeer judge --wait`";
33
+ /** However long `--wait` allows, one judge is stopped after this. */
34
+ const JUDGE_CAP_MS = 15 * 60_000;
35
+ const LINKED_TEST_TIMEOUT_MS = 3 * 60_000;
36
+ const REPRODUCTION_TIMEOUT_MS = 5 * 60_000;
37
+ export const DEFAULT_JUDGE_CONFIG = {
38
+ schemaVersion: JUDGE_CONFIG_SCHEMA_VERSION,
39
+ mode: "report",
40
+ topK: 8,
41
+ concurrency: 3,
42
+ budgetSeconds: 240,
43
+ sampleBelowLine: 0.1,
44
+ alwaysJudge: [],
45
+ client: "auto",
46
+ offers: false,
47
+ offersOn: "fixes",
48
+ maxOffers: 1,
49
+ };
50
+ /**
51
+ * Whether the configuration turns offers on with the key the first exploration
52
+ * used. That key is refused rather than read: its word is one nobody should be
53
+ * asked to learn, and a switch that starts a reading on every push is not one
54
+ * to keep honoring under an old name.
55
+ */
56
+ function usesRetiredOffersKey(input) {
57
+ return input["candidates"] !== undefined;
58
+ }
59
+ /**
60
+ * `.continuity/judge.json`, with the contract's defaults for anything absent.
61
+ * A field that is present and wrong keeps its default and is named, so a typo
62
+ * in a team's config is visible rather than quietly ignored.
63
+ */
64
+ export function readJudgeConfig(root) {
65
+ const path = join(root, JUDGE_CONFIG_PATH);
66
+ if (!existsSync(path))
67
+ return { config: DEFAULT_JUDGE_CONFIG, problems: [] };
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(readFileSync(path, "utf8"));
71
+ }
72
+ catch {
73
+ return {
74
+ config: DEFAULT_JUDGE_CONFIG,
75
+ problems: [`${JUDGE_CONFIG_PATH} is not JSON, so the defaults were used`],
76
+ };
77
+ }
78
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
79
+ return {
80
+ config: DEFAULT_JUDGE_CONFIG,
81
+ problems: [`${JUDGE_CONFIG_PATH} is not an object, so the defaults were used`],
82
+ };
83
+ const input = raw;
84
+ const problems = [];
85
+ const pick = (key, valid, fallback) => {
86
+ if (!(key in input))
87
+ return fallback;
88
+ if (valid(input[key]))
89
+ return input[key];
90
+ problems.push(`${key} in ${JUDGE_CONFIG_PATH} is not valid, so ${JSON.stringify(fallback)} was used`);
91
+ return fallback;
92
+ };
93
+ const whole = (min, max) => (value) => typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
94
+ const d = DEFAULT_JUDGE_CONFIG;
95
+ if (usesRetiredOffersKey(input))
96
+ problems.push(`${JUDGE_CONFIG_PATH} turns offers on with a key that is no longer read; write "offers": true instead`);
97
+ return {
98
+ config: {
99
+ schemaVersion: JUDGE_CONFIG_SCHEMA_VERSION,
100
+ mode: pick("mode", (v) => v === "report" || v === "block", d.mode),
101
+ topK: pick("topK", whole(0, 100), d.topK),
102
+ concurrency: pick("concurrency", whole(1, 16), d.concurrency),
103
+ budgetSeconds: pick("budgetSeconds", (v) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 3600, d.budgetSeconds),
104
+ sampleBelowLine: pick("sampleBelowLine", (v) => typeof v === "number" && v >= 0 && v <= 1, d.sampleBelowLine),
105
+ alwaysJudge: pick("alwaysJudge", (v) => Array.isArray(v) && v.every((id) => typeof id === "string" && PROMISE_ID.test(id)), [...d.alwaysJudge]),
106
+ client: pick("client", (v) => v === "auto" || v === "claude" || v === "codex", d.client),
107
+ offers: pick("offers", (v) => typeof v === "boolean", d.offers),
108
+ offersOn: pick("offersOn", (v) => v === "fixes" || v === "all", d.offersOn),
109
+ maxOffers: pick("maxOffers", whole(1, 3), d.maxOffers),
110
+ },
111
+ problems,
112
+ };
113
+ }
114
+ function runEnvironment(parent) {
115
+ // CI keeps a test runner out of watch mode, which would otherwise hold the
116
+ // command open until its time ran out.
117
+ return { ...agentEnvironment(parent), CI: "1" };
118
+ }
119
+ export async function runShell(command, cwd, options) {
120
+ const run = await runOwnedProcess({
121
+ command: "/bin/sh",
122
+ args: ["-c", command],
123
+ cwd,
124
+ env: runEnvironment(options.env),
125
+ timeoutMs: options.timeoutMs,
126
+ maxOutputBytes: 1024 * 1024,
127
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
128
+ });
129
+ return {
130
+ exitCode: run.spawnError !== undefined ? null : run.exitCode,
131
+ timedOut: run.timedOut || run.bounded,
132
+ aborted: run.aborted,
133
+ output: tail(`${run.stdout}\n${run.stderr}`, 1500),
134
+ };
135
+ }
136
+ function insideDirectory(root, cwd) {
137
+ const target = resolve(root, cwd || ".");
138
+ const inside = relative(root, target);
139
+ return inside.startsWith("..") || isAbsolute(inside) ? undefined : target;
140
+ }
141
+ /**
142
+ * Run a judge's reproduction both ways.
143
+ *
144
+ * At head, in the judge's own checkout, reset to head so a judge that left it
145
+ * on base cannot fool the check. At base, in a fresh checkout of base, with the
146
+ * files the judge added carried across so a new test exists on both sides. It
147
+ * must exit non-zero at head and zero at base; either disagreeing rejects it,
148
+ * and the reason says which.
149
+ */
150
+ export async function verifyReproduction(input) {
151
+ const timeoutMs = input.timeoutMs ?? REPRODUCTION_TIMEOUT_MS;
152
+ const seconds = Math.round(timeoutMs / 1000);
153
+ await runCommand("git", [
154
+ "-C",
155
+ input.headTree,
156
+ "checkout",
157
+ "--quiet",
158
+ "--detach",
159
+ "--force",
160
+ input.head,
161
+ ]);
162
+ const carried = await newFiles(input.headTree);
163
+ const digest = sha256Digest({ command: input.command, cwd: input.cwd, files: carried });
164
+ const reject = (failsOnHead, passesOnBase, rejected) => ({
165
+ failsOnHead,
166
+ passesOnBase,
167
+ digest,
168
+ rejected,
169
+ stopped: false,
170
+ });
171
+ const headCwd = insideDirectory(input.headTree, input.cwd);
172
+ if (headCwd === undefined || !existsSync(headCwd))
173
+ return reject(false, false, `its cwd ${input.cwd} is not a directory inside the repository`);
174
+ const atHead = await runShell(input.command, headCwd, {
175
+ env: input.env,
176
+ timeoutMs,
177
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
178
+ });
179
+ if (atHead.aborted)
180
+ return { failsOnHead: false, passesOnBase: false, digest, rejected: null, stopped: true };
181
+ if (atHead.timedOut)
182
+ return reject(false, false, `it was still running at head after ${seconds} seconds`);
183
+ if (atHead.exitCode === null)
184
+ return reject(false, false, "it could not be started at head");
185
+ if (atHead.exitCode === 0)
186
+ return reject(false, false, "it passes with the change (exit 0), so it does not show a break");
187
+ const tree = await addScratchWorktree(input.root, input.base, "judge-base", input.scratch);
188
+ if (!tree.ok)
189
+ return reject(true, false, `the base commit could not be checked out: ${tree.reason}`);
190
+ try {
191
+ await shareDependencies(input.root, tree.worktree.path);
192
+ carryFiles(input.headTree, tree.worktree.path, carried);
193
+ const baseCwd = insideDirectory(tree.worktree.path, input.cwd);
194
+ if (baseCwd === undefined || !existsSync(baseCwd))
195
+ return reject(true, false, `its cwd ${input.cwd} does not exist without the change`);
196
+ const atBase = await runShell(input.command, baseCwd, {
197
+ env: input.env,
198
+ timeoutMs,
199
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
200
+ });
201
+ if (atBase.aborted)
202
+ return { failsOnHead: true, passesOnBase: false, digest, rejected: null, stopped: true };
203
+ if (atBase.timedOut)
204
+ return reject(true, false, `it was still running at base after ${seconds} seconds`);
205
+ if (atBase.exitCode !== 0)
206
+ return reject(true, false, `it also fails without the change (exit ${atBase.exitCode ?? "none"}), so the failure is not this change's`);
207
+ return { failsOnHead: true, passesOnBase: true, digest, rejected: null, stopped: false };
208
+ }
209
+ finally {
210
+ await tree.worktree.remove();
211
+ }
212
+ }
213
+ /**
214
+ * How this repository runs one test file, when it can be told from the files
215
+ * alone. A runner that cannot be told is `none`, and the judge is left to find
216
+ * its own way.
217
+ */
218
+ export function linkedTestCommand(tree, file) {
219
+ const quoted = `'${file.replace(/'/g, `'\\''`)}'`;
220
+ if (file.endsWith(".py"))
221
+ return `python3 -m pytest -q ${quoted}`;
222
+ if (file.endsWith(".go"))
223
+ return `go test ./${dirname(file)}`;
224
+ if (!/\.(m?[jt]sx?|cjs|cts)$/.test(file))
225
+ return undefined;
226
+ for (const [tool, run] of [
227
+ ["vitest", "run"],
228
+ ["jest", ""],
229
+ ["mocha", ""],
230
+ ]) {
231
+ if (existsSync(join(tree, "node_modules", ".bin", tool)))
232
+ return `node_modules/.bin/${tool}${run ? ` ${run}` : ""} ${quoted}`;
233
+ }
234
+ try {
235
+ if (/from\s+["']node:test["']|require\(["']node:test["']\)/.test(readFileSync(join(tree, file), "utf8")))
236
+ return `node --test ${quoted}`;
237
+ }
238
+ catch {
239
+ /* Unreadable: no command. */
240
+ }
241
+ return undefined;
242
+ }
243
+ /** The judge's final JSON, read strictly enough that a malformed answer is not a verdict. */
244
+ export function readJudgeAnswer(json) {
245
+ if (json === undefined)
246
+ return undefined;
247
+ const verdict = json.verdict;
248
+ if (verdict !== "kept" && verdict !== "broken" && verdict !== "could_not_tell")
249
+ return undefined;
250
+ const raw = json.reproduction;
251
+ const reproduction = raw !== null &&
252
+ typeof raw === "object" &&
253
+ typeof raw.command === "string" &&
254
+ raw.command.trim() !== ""
255
+ ? {
256
+ command: raw.command.trim().slice(0, 2000),
257
+ cwd: typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : ".",
258
+ }
259
+ : undefined;
260
+ const exercisedCases = Array.isArray(json.exercisedCases)
261
+ ? [
262
+ ...new Set(json.exercisedCases.filter((id) => typeof id === "string" && CASE_ID.test(id))),
263
+ ]
264
+ : [];
265
+ return {
266
+ verdict,
267
+ ...(reproduction === undefined ? {} : { reproduction }),
268
+ exercisedCases,
269
+ notes: typeof json.notes === "string" ? json.notes.slice(0, 2000) : "",
270
+ };
271
+ }
272
+ /**
273
+ * One promise judged against one change, with its reproduction verified.
274
+ *
275
+ * Returns `stopped` when the caller's budget ran out first, which is not a
276
+ * verdict and is never logged as one: the push is either held or told the
277
+ * judge is still running, never let through as if it had been judged.
278
+ */
279
+ export async function judgePromise(input) {
280
+ const timeoutMs = input.timeoutMs ?? JUDGE_CAP_MS;
281
+ const tree = await addScratchWorktree(input.root, input.head, `judge-${input.promise.promiseId}`, input.scratch);
282
+ if (!tree.ok)
283
+ return { status: "not_judged", reason: tree.reason };
284
+ const headTree = tree.worktree;
285
+ try {
286
+ await shareDependencies(input.root, headTree.path);
287
+ let linkedTestRun = { file: "", outcome: "none" };
288
+ const linkedTestRuns = [];
289
+ const firstTest = input.map?.linkedTests[0];
290
+ if (firstTest !== undefined) {
291
+ const command = linkedTestCommand(headTree.path, firstTest.file);
292
+ if (command === undefined)
293
+ linkedTestRun = { file: firstTest.file, outcome: "none" };
294
+ else {
295
+ const run = await runShell(command, headTree.path, {
296
+ env: input.env,
297
+ timeoutMs: Math.min(LINKED_TEST_TIMEOUT_MS, timeoutMs),
298
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
299
+ });
300
+ if (run.aborted)
301
+ return { status: "stopped" };
302
+ const outcome = run.timedOut || run.exitCode === null || run.exitCode === 127
303
+ ? "error"
304
+ : run.exitCode === 0
305
+ ? "pass"
306
+ : "fail";
307
+ linkedTestRun = { file: firstTest.file, outcome };
308
+ linkedTestRuns.push({
309
+ file: firstTest.file,
310
+ command,
311
+ outcome,
312
+ output: tail(run.output, 800),
313
+ });
314
+ }
315
+ }
316
+ const agent = await (input.runAgent ?? runHeadlessAgent)({
317
+ client: input.login.client,
318
+ login: input.login,
319
+ cwd: headTree.path,
320
+ prompt: judgePrompt({
321
+ promise: input.promise,
322
+ ...(input.map === undefined ? {} : { map: input.map }),
323
+ changedFiles: input.changedFiles,
324
+ base: input.base,
325
+ head: input.head,
326
+ linkedTestRuns,
327
+ minutes: Math.max(1, Math.floor(timeoutMs / 60_000)),
328
+ }),
329
+ allowedTools: AGENT_TOOLS,
330
+ disallowedTools: REFUSED_TOOL_RULES,
331
+ sandbox: "workspace-write",
332
+ timeoutMs,
333
+ env: input.env,
334
+ jsonOutput: true,
335
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
336
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
337
+ });
338
+ if (agent.aborted)
339
+ return { status: "stopped" };
340
+ if (agent.reason === "no_verified_login")
341
+ return { status: "not_judged", reason: agentFailure(agent) };
342
+ const judge = {
343
+ client: input.login.client,
344
+ model: agent.model ?? "",
345
+ durationMs: agent.durationMs,
346
+ inputTokens: agent.inputTokens ?? 0,
347
+ outputTokens: agent.outputTokens ?? 0,
348
+ };
349
+ const verdict = (fields) => ({
350
+ schemaVersion: JUDGE_VERDICT_SCHEMA_VERSION,
351
+ promiseId: input.promise.promiseId,
352
+ semanticDigest: input.promise.semanticDigest,
353
+ base: input.base,
354
+ head: input.head,
355
+ verdict: "could_not_tell",
356
+ reproduction: null,
357
+ reproductionRejected: null,
358
+ exercisedCases: [],
359
+ notes: "",
360
+ judge,
361
+ briefRevision: JUDGE_BRIEF_REVISION,
362
+ mapUsed: input.map !== undefined,
363
+ linkedTestRun,
364
+ ...fields,
365
+ });
366
+ const answer = agent.ok ? readJudgeAnswer(agent.json) : undefined;
367
+ if (answer === undefined)
368
+ return {
369
+ status: "judged",
370
+ verdict: verdict({
371
+ notes: agent.ok ? "the judge's answer was not a verdict" : agentFailure(agent),
372
+ }),
373
+ };
374
+ if (answer.verdict === "kept") {
375
+ const missing = caseIds(input.promise).passing.filter((id) => !answer.exercisedCases.includes(id));
376
+ if (missing.length > 0)
377
+ return {
378
+ status: "judged",
379
+ verdict: verdict({
380
+ exercisedCases: answer.exercisedCases,
381
+ notes: `The judge answered kept without exercising ${missing.join(", ")}. ${answer.notes}`.trim(),
382
+ }),
383
+ };
384
+ return {
385
+ status: "judged",
386
+ verdict: verdict({
387
+ verdict: "kept",
388
+ exercisedCases: answer.exercisedCases,
389
+ notes: answer.notes,
390
+ }),
391
+ };
392
+ }
393
+ if (answer.verdict === "could_not_tell" || answer.reproduction === undefined)
394
+ return {
395
+ status: "judged",
396
+ verdict: verdict({
397
+ exercisedCases: answer.exercisedCases,
398
+ notes: answer.verdict === "broken"
399
+ ? `The judge answered broken without a command that shows it. ${answer.notes}`.trim()
400
+ : answer.notes,
401
+ }),
402
+ };
403
+ const check = await verifyReproduction({
404
+ root: input.root,
405
+ headTree: headTree.path,
406
+ head: input.head,
407
+ base: input.base,
408
+ command: answer.reproduction.command,
409
+ cwd: answer.reproduction.cwd,
410
+ env: input.env,
411
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
412
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
413
+ });
414
+ if (check.stopped)
415
+ return { status: "stopped" };
416
+ const reproduction = {
417
+ command: answer.reproduction.command,
418
+ cwd: answer.reproduction.cwd,
419
+ failsOnHead: check.failsOnHead,
420
+ passesOnBase: check.passesOnBase,
421
+ digest: check.digest,
422
+ };
423
+ return {
424
+ status: "judged",
425
+ verdict: verdict({
426
+ verdict: check.rejected === null ? "broken" : "could_not_tell",
427
+ reproduction,
428
+ reproductionRejected: check.rejected,
429
+ exercisedCases: answer.exercisedCases,
430
+ notes: answer.notes,
431
+ }),
432
+ };
433
+ }
434
+ finally {
435
+ await headTree.remove();
436
+ }
437
+ }
438
+ export const JUDGE_USAGE = ` ${CLI_INVOCATION} judge [--promises <id,id>] [--top <k>] [--base <ref>] [--head <ref>]
439
+ [--report-only | --block] [--wait] [--json] [--client auto|claude|codex]
440
+ [--from-file <promise.json> [--map <map.json>]]
441
+ [--hook claude|codex|git] [--install-hook [claude|codex|git]]
442
+ Judge whether this change keeps the promises it could break, one promise
443
+ at a time, on your own Claude Code or Codex login. A break counts only
444
+ with a command that fails with the change and passes without it, and
445
+ this command runs it both ways itself; anything unproven is "could not
446
+ tell". Report mode prints one line per promise and never stops anything;
447
+ --block exits 2 on a reproduced break, and when judges are still running
448
+ at the time limit it holds the push rather than letting it through.
449
+ --wait runs without the time limit. Promises come from --promises, else
450
+ the top of \`balladeer risk\`, else every mapped promise, plus
451
+ .continuity/judge.json's alwaysJudge and a small random sample. Verdicts
452
+ are kept in .continuity/judge-log/, which git ignores. --install-hook
453
+ adds the push hook to .claude/settings.json and .codex/hooks.json, or
454
+ .git/hooks/pre-push with git. With "offers": true in that file, the push
455
+ hook also reads the change for rules no promise records and offers at
456
+ most three to whoever is pushing; it records nothing and never holds a
457
+ push (see \`balladeer offers\`).
458
+ `;
459
+ export function parseJudgeArguments(argv) {
460
+ const args = [...argv];
461
+ let promises;
462
+ let topK;
463
+ let base;
464
+ let head;
465
+ let json = false;
466
+ let mode;
467
+ let wait = false;
468
+ let hook;
469
+ let installHook;
470
+ let fromFile;
471
+ let map;
472
+ let repo;
473
+ let client;
474
+ let repository;
475
+ let controlPlane;
476
+ const value = (flag, inline) => {
477
+ const next = inline ?? args.shift();
478
+ if (next === undefined || next === "")
479
+ throw new StoreError("usage", `${flag} needs a value.`);
480
+ return next;
481
+ };
482
+ const host = (flag, requested) => {
483
+ if (requested !== "claude" && requested !== "codex" && requested !== "git")
484
+ throw new StoreError("usage", `${flag} needs claude, codex or git.`);
485
+ return requested;
486
+ };
487
+ while (args.length > 0) {
488
+ const arg = args.shift();
489
+ if (!arg.startsWith("--"))
490
+ throw new StoreError("usage", `judge takes no bare arguments: ${arg}.`);
491
+ const [name, ...rest] = arg.split("=");
492
+ const inline = rest.length > 0 ? rest.join("=") : undefined;
493
+ if (name === "--promises") {
494
+ promises = value(name, inline)
495
+ .split(",")
496
+ .map((id) => id.trim())
497
+ .filter(Boolean);
498
+ const bad = promises.find((id) => !PROMISE_ID.test(id));
499
+ if (bad !== undefined)
500
+ throw new StoreError("usage", `"${bad}" is not a promise id.`);
501
+ }
502
+ else if (name === "--top") {
503
+ const k = Number(value(name, inline));
504
+ if (!Number.isInteger(k) || k < 0 || k > 100)
505
+ throw new StoreError("usage", "--top needs a whole number from 0 to 100.");
506
+ topK = k;
507
+ }
508
+ else if (name === "--base")
509
+ base = value(name, inline);
510
+ else if (name === "--head")
511
+ head = value(name, inline);
512
+ else if (name === "--json")
513
+ json = true;
514
+ else if (name === "--report-only")
515
+ mode = "report";
516
+ else if (name === "--block")
517
+ mode = "block";
518
+ else if (name === "--wait")
519
+ wait = true;
520
+ else if (name === "--hook")
521
+ hook = host(name, value(name, inline));
522
+ else if (name === "--install-hook") {
523
+ const next = inline ?? (["claude", "codex", "git"].includes(args[0] ?? "") ? args.shift() : undefined);
524
+ installHook = next === undefined ? "agents" : host(name, next);
525
+ }
526
+ else if (name === "--from-file")
527
+ fromFile = value(name, inline);
528
+ else if (name === "--map")
529
+ map = value(name, inline);
530
+ else if (name === "--repo")
531
+ repo = value(name, inline);
532
+ else if (name === "--repository")
533
+ repository = value(name, inline);
534
+ else if (name === "--control-plane")
535
+ controlPlane = value(name, inline);
536
+ else if (name === "--client") {
537
+ const requested = value(name, inline);
538
+ if (requested !== "auto" && requested !== "claude" && requested !== "codex")
539
+ throw new StoreError("usage", "--client needs auto, claude or codex.");
540
+ client = requested;
541
+ }
542
+ else
543
+ throw new StoreError("usage", `Unknown option ${arg} for judge.`);
544
+ }
545
+ if (map !== undefined && fromFile === undefined)
546
+ throw new StoreError("usage", "--map goes with --from-file; a mapped promise's own map is read by itself.");
547
+ if (fromFile !== undefined && promises !== undefined)
548
+ throw new StoreError("usage", "--from-file judges the promise in that file; leave out --promises.");
549
+ const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
550
+ return {
551
+ ...(promises === undefined ? {} : { promises }),
552
+ ...(topK === undefined ? {} : { topK }),
553
+ ...(base === undefined ? {} : { base }),
554
+ ...(head === undefined ? {} : { head }),
555
+ json,
556
+ ...(mode === undefined ? {} : { mode }),
557
+ wait,
558
+ ...(hook === undefined ? {} : { hook }),
559
+ ...(installHook === undefined ? {} : { installHook }),
560
+ ...(fromFile === undefined ? {} : { fromFile }),
561
+ ...(map === undefined ? {} : { map }),
562
+ ...(repo === undefined ? {} : { repo }),
563
+ ...(client === undefined ? {} : { client }),
564
+ ...(repository === undefined ? {} : { repository }),
565
+ controlPlane: normalizeControlPlane(chosen),
566
+ };
567
+ }
568
+ /** Read a risk report out of whatever `balladeer risk --json` printed. */
569
+ export function readRiskOutput(stdout) {
570
+ const documents = [];
571
+ try {
572
+ documents.push(JSON.parse(stdout));
573
+ }
574
+ catch {
575
+ for (const line of stdout.split("\n")) {
576
+ try {
577
+ documents.push(JSON.parse(line));
578
+ }
579
+ catch {
580
+ /* Not a JSON line. */
581
+ }
582
+ }
583
+ }
584
+ for (const document of documents) {
585
+ const report = document?.schemaVersion === "balladeer-risk/v1"
586
+ ? document
587
+ : document?.report;
588
+ if (report?.schemaVersion !== "balladeer-risk/v1")
589
+ continue;
590
+ const rows = report.promises;
591
+ if (!Array.isArray(rows))
592
+ continue;
593
+ return rows.flatMap((row) => {
594
+ const entry = row;
595
+ return typeof entry.promiseId === "string" && typeof entry.score === "number"
596
+ ? [{ promiseId: entry.promiseId, score: entry.score, band: String(entry.band ?? "") }]
597
+ : [];
598
+ });
599
+ }
600
+ return undefined;
601
+ }
602
+ /** `balladeer risk --json`, run as a child of this same command, when this build has it. */
603
+ async function riskFromCommand(root, base, env) {
604
+ const entry = process.argv[1];
605
+ if (entry === undefined || !/(^|[/\\])(cli\.(m?js|ts)|balladeer)$/.test(entry))
606
+ return undefined;
607
+ const run = await runOwnedProcess({
608
+ command: process.execPath,
609
+ args: [...process.execArgv, entry, "risk", "--json", "--base", base],
610
+ cwd: root,
611
+ env: { ...env, [JUDGE_ACTIVE_VARIABLE]: "1" },
612
+ timeoutMs: 60_000,
613
+ maxOutputBytes: 4 * 1024 * 1024,
614
+ });
615
+ return run.exitCode === 0 ? readRiskOutput(run.stdout) : undefined;
616
+ }
617
+ /**
618
+ * The commit a change is measured from: the fork point with the remote default
619
+ * branch, else with this branch's upstream, else the parent of head.
620
+ */
621
+ export async function defaultBase(root, head) {
622
+ const branches = [];
623
+ const remoteHead = await runCommand("git", [
624
+ "-C",
625
+ root,
626
+ "rev-parse",
627
+ "--abbrev-ref",
628
+ "origin/HEAD",
629
+ ]);
630
+ if (remoteHead.ok && remoteHead.stdout.trim() && remoteHead.stdout.trim() !== "origin/HEAD")
631
+ branches.push(remoteHead.stdout.trim());
632
+ branches.push("origin/main", "origin/master", "@{upstream}");
633
+ for (const branch of branches) {
634
+ const ref = await resolveCommit(root, branch);
635
+ if (ref === undefined)
636
+ continue;
637
+ const forkPoint = await runCommand("git", ["-C", root, "merge-base", head, ref]);
638
+ const sha = forkPoint.stdout.trim();
639
+ if (forkPoint.ok && /^[0-9a-f]{40}$/.test(sha) && sha !== head)
640
+ return sha;
641
+ }
642
+ return resolveCommit(root, `${head}^`);
643
+ }
644
+ export async function changedFiles(root, base, head) {
645
+ const diff = await runCommand("git", ["-C", root, "diff", "--name-only", base, head], {
646
+ timeoutMs: 60_000,
647
+ });
648
+ return diff.ok ? diff.stdout.split("\n").filter((line) => line.trim() !== "") : [];
649
+ }
650
+ /**
651
+ * Which promises to judge: named ones, else the top of the risk report plus a
652
+ * random sample below its line, else every mapped promise; then `alwaysJudge`.
653
+ * The sample is what keeps the ranking honest: a break found below the line is
654
+ * a miss the ranking made, counted rather than never seen.
655
+ */
656
+ export function selectPromises(input) {
657
+ const out = [];
658
+ const seen = new Set();
659
+ const add = (promiseId, why) => {
660
+ if (seen.has(promiseId))
661
+ return;
662
+ seen.add(promiseId);
663
+ out.push({ promiseId, why });
664
+ };
665
+ if (input.named !== undefined)
666
+ for (const id of input.named)
667
+ add(id, "named");
668
+ else if (input.risk !== undefined) {
669
+ const k = input.topK ?? input.config.topK;
670
+ const ranked = input.risk
671
+ .filter((entry) => entry.band !== "unmapped" && entry.score > 0)
672
+ .sort((a, b) => b.score - a.score || a.promiseId.localeCompare(b.promiseId));
673
+ const top = ranked.slice(0, k);
674
+ for (const entry of top)
675
+ add(entry.promiseId, "risk");
676
+ const below = input.risk.filter((entry) => entry.band !== "unmapped" && !top.some((row) => row.promiseId === entry.promiseId));
677
+ for (const entry of below)
678
+ if (input.random() < input.config.sampleBelowLine)
679
+ add(entry.promiseId, "sample");
680
+ }
681
+ else
682
+ for (const id of input.mapped)
683
+ add(id, "mapped");
684
+ for (const id of input.config.alwaysJudge)
685
+ add(id, "always");
686
+ return out;
687
+ }
688
+ function logPath(root, now) {
689
+ // One file per UTC day. A file name, not a sentence: nobody reads it as a time.
690
+ const day = now.toISOString().slice(0, 10);
691
+ return join(root, JUDGE_LOG_DIRECTORY, `${day}.jsonl`);
692
+ }
693
+ /**
694
+ * Append verdicts to today's log. The directory carries its own `.gitignore`,
695
+ * so a verdict log never lands in a commit whatever the repository ignores.
696
+ */
697
+ export function appendVerdicts(root, verdicts, now) {
698
+ if (verdicts.length === 0)
699
+ return;
700
+ const directory = join(root, JUDGE_LOG_DIRECTORY);
701
+ mkdirSync(directory, { recursive: true });
702
+ const ignore = join(directory, ".gitignore");
703
+ if (!existsSync(ignore))
704
+ writeFileSync(ignore, "*\n", "utf8");
705
+ appendFileSync(logPath(root, now), verdicts.map((verdict) => `${JSON.stringify(verdict)}\n`).join(""), "utf8");
706
+ }
707
+ /** A verdict already reached for exactly this promise, meaning, change and brief. */
708
+ export function findLoggedVerdict(root, key, now) {
709
+ const days = [now, new Date(now.getTime() - 86_400_000)];
710
+ for (const day of days) {
711
+ const path = logPath(root, day);
712
+ if (!existsSync(path))
713
+ continue;
714
+ const lines = readFileSync(path, "utf8").split("\n").reverse();
715
+ for (const line of lines) {
716
+ if (!line.trim())
717
+ continue;
718
+ try {
719
+ const verdict = JSON.parse(line);
720
+ if (verdict.schemaVersion === JUDGE_VERDICT_SCHEMA_VERSION &&
721
+ verdict.promiseId === key.promiseId &&
722
+ verdict.semanticDigest === key.semanticDigest &&
723
+ verdict.base === key.base &&
724
+ verdict.head === key.head &&
725
+ verdict.briefRevision === JUDGE_BRIEF_REVISION)
726
+ return verdict;
727
+ }
728
+ catch {
729
+ /* A torn line is skipped. */
730
+ }
731
+ }
732
+ }
733
+ return undefined;
734
+ }
735
+ function sentence(text, limit = 240) {
736
+ const flat = text.replace(/\s+/g, " ").trim();
737
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 3)}...`;
738
+ }
739
+ /** One line per verdict, the promise named by its title and id. */
740
+ export function verdictLine(verdict, title, reused = false) {
741
+ const name = `${title} (${verdict.promiseId})`;
742
+ const earlier = reused ? " [judged earlier for this commit]" : "";
743
+ if (verdict.verdict === "kept")
744
+ return `kept: ${name}${earlier}`;
745
+ if (verdict.verdict === "broken") {
746
+ const where = verdict.reproduction && verdict.reproduction.cwd !== "."
747
+ ? ` (in ${verdict.reproduction.cwd})`
748
+ : "";
749
+ return `broken: ${name}${earlier}. Reproduce with: ${verdict.reproduction?.command ?? ""}${where}`;
750
+ }
751
+ const rejected = verdict.reproductionRejected
752
+ ? ` A reported break did not hold up when run both ways: ${verdict.reproductionRejected}.`
753
+ : "";
754
+ const notes = verdict.notes ? ` ${sentence(verdict.notes)}` : "";
755
+ return `could not tell: ${name}${earlier}.${notes}${rejected}`;
756
+ }
757
+ async function readStdin(limit = 1024 * 1024, idleMs = 5000) {
758
+ if (process.stdin.isTTY)
759
+ return "";
760
+ return new Promise((resolveText) => {
761
+ const chunks = [];
762
+ let size = 0;
763
+ let timer = setTimeout(done, idleMs);
764
+ function done() {
765
+ clearTimeout(timer);
766
+ process.stdin.removeAllListeners("data");
767
+ process.stdin.removeAllListeners("end");
768
+ process.stdin.pause();
769
+ resolveText(Buffer.concat(chunks).toString("utf8"));
770
+ }
771
+ process.stdin.on("data", (chunk) => {
772
+ size += chunk.length;
773
+ if (size <= limit)
774
+ chunks.push(chunk);
775
+ clearTimeout(timer);
776
+ timer = setTimeout(done, idleMs);
777
+ });
778
+ process.stdin.on("end", done);
779
+ process.stdin.resume();
780
+ });
781
+ }
782
+ function describeInstall(result) {
783
+ if (result.status === "written")
784
+ return `Written: ${result.path}.`;
785
+ if (result.status === "unchanged")
786
+ return `Already current: ${result.path}. Nothing was changed.`;
787
+ return `Not changed: ${result.path}. ${result.reason ?? ""}`.trim();
788
+ }
789
+ /** `balladeer judge`, from arguments already parsed. */
790
+ export async function runJudge(options) {
791
+ const { args } = options;
792
+ const deps = options.deps ?? {};
793
+ const now = deps.now ?? (() => new Date());
794
+ const hook = args.hook;
795
+ const json = args.json && hook === undefined;
796
+ const lines = [];
797
+ const emit = (step) => {
798
+ if (json)
799
+ options.write(`${JSON.stringify(step)}\n`);
800
+ };
801
+ // Started once the change is known, when the team turned offers on: the
802
+ // rules this change establishes that no promise records. It spends from the
803
+ // hook's own budget and is awaited only at the moment of answering, so it
804
+ // never delays a judge; whatever it does, it never changes the answer's
805
+ // exit, and a reading still running at the deadline is stopped and says
806
+ // nothing.
807
+ let offerReading;
808
+ let offerDeadline = 0;
809
+ const offerStop = new AbortController();
810
+ const settleOffers = async () => {
811
+ if (offerReading === undefined)
812
+ return [];
813
+ let timer;
814
+ const late = new Promise((done) => {
815
+ timer = setTimeout(() => {
816
+ offerStop.abort();
817
+ done([]);
818
+ }, Math.max(0, offerDeadline - Date.now()));
819
+ });
820
+ try {
821
+ return await Promise.race([offerReading.catch(() => []), late]);
822
+ }
823
+ finally {
824
+ if (timer !== undefined)
825
+ clearTimeout(timer);
826
+ }
827
+ };
828
+ const respond = async (block) => {
829
+ const offered = await settleOffers();
830
+ // Apart from the verdicts, after them, with a line between.
831
+ if (offered.length > 0)
832
+ lines.push(...(lines.length > 0 ? [""] : []), ...offered);
833
+ const response = hookResponse(hook ?? "git", { block, lines });
834
+ if (!json) {
835
+ if (response.stdout)
836
+ options.write(response.stdout);
837
+ if (response.stderr && (hook !== undefined || block))
838
+ options.error(response.stderr);
839
+ }
840
+ return response.exitCode;
841
+ };
842
+ // A judge's own shell commands run with this set, and a push hook inside a
843
+ // judge would otherwise start judging all over again.
844
+ if (hook !== undefined && options.environment[JUDGE_ACTIVE_VARIABLE] === "1")
845
+ return 0;
846
+ let cwd = options.cwd;
847
+ let pushedHead;
848
+ if (hook !== undefined) {
849
+ const input = await (deps.stdin ?? (() => readStdin()))();
850
+ if (hook === "git") {
851
+ const refs = parsePrePushInput(input);
852
+ if (refs.length === 0)
853
+ return 0;
854
+ pushedHead = refs[0].localSha;
855
+ }
856
+ else {
857
+ const parsed = parseHookInput(input);
858
+ if (parsed?.command === undefined || !isPushCommand(parsed.command))
859
+ return 0;
860
+ if (parsed.cwd !== undefined && existsSync(parsed.cwd))
861
+ cwd = parsed.cwd;
862
+ }
863
+ }
864
+ const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(cwd);
865
+ if (root === undefined || !existsSync(root)) {
866
+ if (hook !== undefined)
867
+ return 0;
868
+ const message = "This is not a git repository, so there is no change to judge.";
869
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
870
+ if (!json)
871
+ options.error(`${message}\n`);
872
+ return 4;
873
+ }
874
+ if (args.installHook !== undefined) {
875
+ const targets = args.installHook === "agents" ? ["claude", "codex"] : [args.installHook];
876
+ let refused = false;
877
+ for (const target of targets) {
878
+ const result = installJudgeHook(root, target);
879
+ refused ||= result.status === "refused";
880
+ emit({ step: "hook", path: result.path, changed: result.status === "written" });
881
+ if (!json)
882
+ options.write(`${describeInstall(result)}\n`);
883
+ }
884
+ if (!json) {
885
+ options.write('It runs `balladeer judge` before a push or a pull request and says nothing for any other command. Report mode is the default; set "mode": "block" in .continuity/judge.json to stop a push on a reproduced break.\n');
886
+ if (targets.includes("codex"))
887
+ options.write("Codex runs a project hook once you trust it: open /hooks in Codex and review it.\n");
888
+ }
889
+ return refused ? 4 : 0;
890
+ }
891
+ const { config, problems } = readJudgeConfig(root);
892
+ const mode = args.mode ?? config.mode;
893
+ for (const problem of problems)
894
+ lines.push(`Balladeer judge: ${problem}.`);
895
+ await runCommand("git", ["-C", root, "worktree", "prune"]);
896
+ const head = await resolveCommit(root, args.head ?? pushedHead ?? "HEAD");
897
+ if (head === undefined) {
898
+ const message = `${args.head ?? "HEAD"} is not a commit here, so there is nothing to judge.`;
899
+ if (hook !== undefined)
900
+ return 0;
901
+ emit({ step: "error", reason: "no_head", message, changed: false, exitCode: 4 });
902
+ if (!json)
903
+ options.error(`${message}\n`);
904
+ return 4;
905
+ }
906
+ const base = args.base !== undefined ? await resolveCommit(root, args.base) : await defaultBase(root, head);
907
+ if (base === undefined || base === head) {
908
+ const message = base === undefined
909
+ ? `${args.base ?? "the base"} is not a commit here, so nothing was judged.`
910
+ : "Nothing to judge: the change is empty.";
911
+ emit({
912
+ step: "judge",
913
+ status: "nothing_to_judge",
914
+ reason: base === undefined ? "no_base" : "empty_change",
915
+ message,
916
+ exitCode: 0,
917
+ });
918
+ if (hook === undefined)
919
+ lines.push(message);
920
+ return respond(false);
921
+ }
922
+ const files = await changedFiles(root, base, head);
923
+ // Which promises, before spending anything on a login check.
924
+ let filePromise;
925
+ let fileMap;
926
+ if (args.fromFile !== undefined) {
927
+ const read = readPromiseFile(resolve(options.cwd, args.fromFile));
928
+ if (!read.ok) {
929
+ emit({
930
+ step: "error",
931
+ reason: "promise_unreadable",
932
+ message: read.reason,
933
+ changed: false,
934
+ exitCode: 4,
935
+ });
936
+ if (!json)
937
+ options.error(`${read.reason}\n`);
938
+ return 4;
939
+ }
940
+ filePromise = read.promise;
941
+ if (args.map !== undefined) {
942
+ try {
943
+ const parsed = parseBehaviorMap(JSON.parse(readFileSync(resolve(options.cwd, args.map), "utf8")));
944
+ if (!parsed.ok)
945
+ throw new Error(parsed.errors.slice(0, 3).join("; "));
946
+ fileMap = parsed.value;
947
+ }
948
+ catch (error) {
949
+ const message = `${args.map} is not a usable behavior map: ${error instanceof Error ? error.message : String(error)}`;
950
+ emit({ step: "error", reason: "map_unreadable", message, changed: false, exitCode: 4 });
951
+ if (!json)
952
+ options.error(`${message}\n`);
953
+ return 4;
954
+ }
955
+ }
956
+ }
957
+ if (config.offers && hook !== undefined) {
958
+ // Even when no promise is at risk and no map exists: a change nobody
959
+ // mapped is exactly where a rule nobody wrote down is likeliest.
960
+ offerDeadline = Date.now() + effectiveBudgetSeconds(config.budgetSeconds, true) * 1000;
961
+ offerReading = offersForPush({
962
+ root,
963
+ base,
964
+ head,
965
+ files,
966
+ host: hook,
967
+ environment: options.environment,
968
+ controlPlane: args.controlPlane,
969
+ client: args.client ?? config.client,
970
+ deadline: offerDeadline,
971
+ signal: offerStop.signal,
972
+ policy: config.offersOn,
973
+ max: config.maxOffers,
974
+ ...(deps.reader === undefined ? {} : { reader: deps.reader }),
975
+ ...(deps.offerAgent === undefined ? {} : { runAgent: deps.offerAgent }),
976
+ });
977
+ }
978
+ const mappedIds = readBehaviorMaps(join(root, ".continuity/behavior-maps"))
979
+ .filter((entry) => entry.map !== undefined)
980
+ .map((entry) => entry.promiseId);
981
+ const named = filePromise !== undefined ? [filePromise.promiseId] : args.promises;
982
+ const risk = named === undefined
983
+ ? await (deps.riskReport ?? ((r, b) => riskFromCommand(r, b, options.environment)))(root, base, head)
984
+ : undefined;
985
+ const selected = selectPromises({
986
+ ...(named === undefined ? {} : { named }),
987
+ ...(risk === undefined ? {} : { risk }),
988
+ mapped: mappedIds,
989
+ // A promise handed over in a file is judged alone: the repository's own
990
+ // standing list belongs to its own promises, not to the one in the file.
991
+ config: filePromise === undefined ? config : { ...config, alwaysJudge: [] },
992
+ ...(args.topK === undefined ? {} : { topK: args.topK }),
993
+ random: deps.random ?? Math.random,
994
+ });
995
+ if (selected.length === 0) {
996
+ const message = "No promise here has a behavior map yet, so nothing was judged. Build them with `balladeer map --all`.";
997
+ emit({ step: "judge", status: "nothing_to_judge", reason: "no_maps", message, exitCode: 0 });
998
+ lines.push(message);
999
+ return respond(false);
1000
+ }
1001
+ const prefer = hook === "claude" || hook === "codex" ? hook : undefined;
1002
+ const requested = args.client ?? config.client;
1003
+ const login = await (deps.resolveLogin ??
1004
+ ((client, preferred) => resolveClient(client, {
1005
+ env: options.environment,
1006
+ cwd: root,
1007
+ ...(preferred === undefined ? {} : { prefer: preferred }),
1008
+ })))(requested, prefer);
1009
+ if (login === undefined) {
1010
+ const message = "not judged: no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine, so Balladeer did not judge this change.";
1011
+ emit({ step: "judge", status: "not_judged", reason: "no_login", message, exitCode: 0 });
1012
+ lines.push(message);
1013
+ return respond(false);
1014
+ }
1015
+ // Meanings: the file, else this repository's connection, else the map's own excerpt.
1016
+ let reader = deps.reader ?? undefined;
1017
+ if (deps.reader === undefined && filePromise === undefined) {
1018
+ const connection = connectionReader({
1019
+ environment: options.environment,
1020
+ controlPlane: args.controlPlane,
1021
+ cwd: root,
1022
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
1023
+ });
1024
+ if (connection.ok)
1025
+ reader = connection.reader;
1026
+ }
1027
+ const jobs = [];
1028
+ const unreadable = [];
1029
+ for (const { promiseId } of selected) {
1030
+ const map = filePromise !== undefined ? fileMap : readBehaviorMap(root, promiseId);
1031
+ let promise = filePromise;
1032
+ if (promise === undefined && reader !== undefined) {
1033
+ const read = await reader.get(promiseId);
1034
+ if (read.ok)
1035
+ promise = read.promise;
1036
+ }
1037
+ if (promise === undefined && map?.meaning !== undefined)
1038
+ promise = promiseFromExcerpt(promiseId, map.semanticDigest, map.meaning);
1039
+ if (promise === undefined)
1040
+ unreadable.push(promiseId);
1041
+ else
1042
+ jobs.push({ promise, ...(map === undefined ? {} : { map }) });
1043
+ }
1044
+ const stopHandler = () => {
1045
+ removeLiveWorktreesNow();
1046
+ process.exit(130);
1047
+ };
1048
+ process.once("SIGINT", stopHandler);
1049
+ process.once("SIGTERM", stopHandler);
1050
+ const budget = new AbortController();
1051
+ // From a hook, the host's own timeout rules and a timed-out hook lets the
1052
+ // push run, so the budget is capped below it; by hand, the person's number.
1053
+ const budgetMs = args.wait
1054
+ ? undefined
1055
+ : effectiveBudgetSeconds(config.budgetSeconds, args.hook !== undefined) * 1000;
1056
+ const timer = budgetMs === undefined ? undefined : setTimeout(() => budget.abort(), budgetMs);
1057
+ const started = Date.now();
1058
+ const verdicts = [];
1059
+ const stillJudging = [];
1060
+ const notJudged = [];
1061
+ try {
1062
+ const queue = [...jobs];
1063
+ const worker = async () => {
1064
+ for (;;) {
1065
+ const job = queue.shift();
1066
+ if (job === undefined)
1067
+ return;
1068
+ const logged = findLoggedVerdict(root, {
1069
+ promiseId: job.promise.promiseId,
1070
+ semanticDigest: job.promise.semanticDigest,
1071
+ base,
1072
+ head,
1073
+ }, now());
1074
+ if (logged !== undefined) {
1075
+ verdicts.push({ verdict: logged, title: job.promise.title, reused: true });
1076
+ continue;
1077
+ }
1078
+ if (budget.signal.aborted) {
1079
+ stillJudging.push(job);
1080
+ continue;
1081
+ }
1082
+ const remaining = budgetMs === undefined ? JUDGE_CAP_MS : Math.max(1000, budgetMs - (Date.now() - started));
1083
+ const outcome = await (deps.judge ?? judgePromise)({
1084
+ root,
1085
+ promise: job.promise,
1086
+ ...(job.map === undefined ? {} : { map: job.map }),
1087
+ base,
1088
+ head,
1089
+ changedFiles: files,
1090
+ login,
1091
+ env: options.environment,
1092
+ timeoutMs: Math.min(JUDGE_CAP_MS, remaining),
1093
+ signal: budget.signal,
1094
+ ...(deps.scratch === undefined ? {} : { scratch: deps.scratch }),
1095
+ ...(deps.runAgent === undefined ? {} : { runAgent: deps.runAgent }),
1096
+ });
1097
+ if (outcome.status === "stopped")
1098
+ stillJudging.push(job);
1099
+ else if (outcome.status === "not_judged")
1100
+ notJudged.push({ id: job.promise.promiseId, reason: outcome.reason });
1101
+ else {
1102
+ verdicts.push({ verdict: outcome.verdict, title: job.promise.title, reused: false });
1103
+ appendVerdicts(root, [outcome.verdict], now());
1104
+ }
1105
+ }
1106
+ };
1107
+ await Promise.all(Array.from({ length: Math.min(config.concurrency, Math.max(1, jobs.length)) }, worker));
1108
+ }
1109
+ finally {
1110
+ if (timer !== undefined)
1111
+ clearTimeout(timer);
1112
+ process.removeListener("SIGINT", stopHandler);
1113
+ process.removeListener("SIGTERM", stopHandler);
1114
+ }
1115
+ const order = new Map(selected.map((entry, index) => [entry.promiseId, index]));
1116
+ verdicts.sort((a, b) => (order.get(a.verdict.promiseId) ?? 0) - (order.get(b.verdict.promiseId) ?? 0));
1117
+ const broken = verdicts.filter((entry) => entry.verdict.verdict === "broken");
1118
+ if (json)
1119
+ for (const { verdict } of verdicts)
1120
+ options.write(`${JSON.stringify(verdict)}\n`);
1121
+ 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
+ for (const { verdict, title, reused } of verdicts)
1123
+ lines.push(` ${verdictLine(verdict, title, reused)}`);
1124
+ for (const { id, reason } of notJudged)
1125
+ lines.push(` not judged: ${id}: ${reason}`);
1126
+ for (const id of unreadable)
1127
+ lines.push(` not judged: ${id}: its meaning could not be read here and it has no behavior map.`);
1128
+ if (stillJudging.length > 0) {
1129
+ const ids = stillJudging.map((job) => job.promise.promiseId);
1130
+ const names = stillJudging
1131
+ .map((job) => `${job.promise.title} (${job.promise.promiseId})`)
1132
+ .join("; ");
1133
+ const message = mode === "block"
1134
+ ? `Held: ${STILL_JUDGING}, then push again. Still judging when the time ran out: ${names}.`
1135
+ : `Still judging when the time ran out, so not judged yet: ${names}. Run \`balladeer judge --wait\` to finish them.`;
1136
+ lines.push(message);
1137
+ emit({
1138
+ step: "judge",
1139
+ status: "still_judging",
1140
+ reason: "budget_spent",
1141
+ message,
1142
+ promiseIds: ids,
1143
+ exitCode: mode === "block" ? 2 : 0,
1144
+ });
1145
+ }
1146
+ if (broken.length > 0 && mode === "block")
1147
+ 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.");
1148
+ const block = mode === "block" && (broken.length > 0 || stillJudging.length > 0);
1149
+ if (json)
1150
+ return block ? 2 : 0;
1151
+ return respond(block);
1152
+ }
1153
+ /** `balladeer judge ...` from raw arguments, as `cli.ts` hands them over. */
1154
+ export async function judgeCommand(argv, io) {
1155
+ let args;
1156
+ try {
1157
+ args = parseJudgeArguments(argv);
1158
+ }
1159
+ catch (error) {
1160
+ io.error(`${error instanceof Error ? error.message : String(error)}\n\n${JUDGE_USAGE}`);
1161
+ return 4;
1162
+ }
1163
+ return runJudge({
1164
+ args,
1165
+ cwd: io.cwd,
1166
+ environment: io.environment,
1167
+ write: io.write,
1168
+ error: io.error,
1169
+ });
1170
+ }