balladeer 1.0.15 → 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 (65) 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 +55 -0
  6. package/dist/commands/judge.d.ts +271 -0
  7. package/dist/commands/judge.js +1170 -0
  8. package/dist/commands/map.d.ts +120 -0
  9. package/dist/commands/map.js +601 -0
  10. package/dist/commands/offers.d.ts +265 -0
  11. package/dist/commands/offers.js +801 -0
  12. package/dist/commands/risk.d.ts +29 -0
  13. package/dist/commands/risk.js +133 -0
  14. package/dist/copy.d.ts +24 -1
  15. package/dist/copy.js +91 -0
  16. package/dist/guidance-hook.mjs +71 -1
  17. package/dist/headless-agent.d.ts +166 -0
  18. package/dist/headless-agent.js +416 -0
  19. package/dist/judge-brief.d.ts +36 -0
  20. package/dist/judge-brief.js +89 -0
  21. package/dist/judge-hook.d.ts +84 -0
  22. package/dist/judge-hook.js +420 -0
  23. package/dist/map-brief.d.ts +16 -0
  24. package/dist/map-brief.js +41 -0
  25. package/dist/offer-brief.d.ts +95 -0
  26. package/dist/offer-brief.js +217 -0
  27. package/dist/owned-process.d.ts +65 -0
  28. package/dist/owned-process.js +146 -0
  29. package/dist/promise-meaning.d.ts +126 -0
  30. package/dist/promise-meaning.js +292 -0
  31. package/dist/risk/contract.d.ts +145 -0
  32. package/dist/risk/contract.js +74 -0
  33. package/dist/risk/describe.d.ts +7 -0
  34. package/dist/risk/describe.js +45 -0
  35. package/dist/risk/diff.d.ts +26 -0
  36. package/dist/risk/diff.js +174 -0
  37. package/dist/risk/extract.d.ts +43 -0
  38. package/dist/risk/extract.js +336 -0
  39. package/dist/risk/git.d.ts +30 -0
  40. package/dist/risk/git.js +118 -0
  41. package/dist/risk/import-graph.d.ts +41 -0
  42. package/dist/risk/import-graph.js +487 -0
  43. package/dist/risk/index.d.ts +20 -0
  44. package/dist/risk/index.js +20 -0
  45. package/dist/risk/paths.d.ts +16 -0
  46. package/dist/risk/paths.js +73 -0
  47. package/dist/risk/pipeline.d.ts +47 -0
  48. package/dist/risk/pipeline.js +121 -0
  49. package/dist/risk/priors.d.ts +18 -0
  50. package/dist/risk/priors.js +86 -0
  51. package/dist/risk/resources.d.ts +56 -0
  52. package/dist/risk/resources.js +374 -0
  53. package/dist/risk/score.d.ts +85 -0
  54. package/dist/risk/score.js +543 -0
  55. package/dist/risk/symbols.d.ts +35 -0
  56. package/dist/risk/symbols.js +348 -0
  57. package/dist/risk/text.d.ts +43 -0
  58. package/dist/risk/text.js +277 -0
  59. package/dist/risk/validate.d.ts +19 -0
  60. package/dist/risk/validate.js +154 -0
  61. package/dist/scratch-worktree.d.ts +51 -0
  62. package/dist/scratch-worktree.js +153 -0
  63. package/dist/wire.d.ts +52 -3
  64. package/dist/wire.js +2 -2
  65. package/package.json +1 -1
@@ -0,0 +1,801 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { PROMISE_ID } from "../behavior-map-schema.js";
4
+ import { repositoryRoot } from "../git.js";
5
+ import { JUDGE_ACTIVE_VARIABLE, runHeadlessAgent, } from "../headless-agent.js";
6
+ import { MAX_OFFERS, MAX_RULES_READ, OFFER_BRIEF_REVISION, OFFER_QUESTION, boundDiff, grouped, offerPrompt, } from "../offer-brief.js";
7
+ import { runOwnedProcess } from "../owned-process.js";
8
+ import { connectionReader, normalizePromise, readPromiseFile, } from "../promise-meaning.js";
9
+ import { readBehaviorMaps } from "../risk/pipeline.js";
10
+ import { resolveCommit } from "../scratch-worktree.js";
11
+ import { normalizeControlPlane, StoreError } from "../store.js";
12
+ import { CLI_INVOCATION, DEFAULT_CONTROL_PLANE } from "../wire.js";
13
+ import { changedFiles, defaultBase } from "./judge.js";
14
+ /**
15
+ * `balladeer offers`: the rules a change establishes that no promise records,
16
+ * for the person pushing it to be offered in that same session.
17
+ *
18
+ * It reads and reports. It stores nothing, records nothing and proposes
19
+ * nothing: a rule becomes a proposal only when the person says yes to the
20
+ * question each offer carries, in the session this hands its findings to, and
21
+ * that session records it. No offer outlives the session it was made in.
22
+ */
23
+ export const OFFERS_SCHEMA_VERSION = "balladeer-offers/v1";
24
+ /** The most existing promises read through the connection for one reading. */
25
+ export const MAX_EXISTING_PROMISES = 200;
26
+ /** How long `balladeer offers` gives the reader by hand, unless told otherwise. */
27
+ export const DEFAULT_OFFER_TIMEOUT_SECONDS = 240;
28
+ /**
29
+ * What the pushing session is told to do with the offers, stated once after
30
+ * them. It asks for the offers to be shown word for word because the first
31
+ * Codex canary's agent, told only to "offer these", answered that no hook
32
+ * message had appeared: in four sessions it surfaced them once, and with this
33
+ * wording three times in three.
34
+ */
35
+ export const OFFER_INSTRUCTIONS = "Show the person these offers now, word for word and apart from the push, each ending with that question, unless your Balladeer guidance says to make no unsolicited offers here. " +
36
+ "A yes to that question, and only that, is consent to record that one: then record it with propose_promise, with your offer and their words of agreement, kept apart, as the explicit intent and the rule as your interpretation, and never quote your wording as theirs. " +
37
+ "A yes to the push is not that yes. " +
38
+ "If they decline or do not answer, say nothing was recorded and do not raise it again. " +
39
+ "Nothing has been stored.";
40
+ // ---------------------------------------------------------------------------
41
+ // Reading the reader's answer.
42
+ const text = (value, max) => {
43
+ if (typeof value !== "string")
44
+ return undefined;
45
+ const trimmed = value.trim();
46
+ return trimmed.length > 0 && trimmed.length <= max ? trimmed : undefined;
47
+ };
48
+ /** A path as the change names it: forward slashes, no `./`, no diff side prefix. */
49
+ function changedPath(value, changed) {
50
+ const path = value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
51
+ if (changed.has(path))
52
+ return path;
53
+ const unprefixed = path.replace(/^[ab]\//, "");
54
+ return changed.has(unprefixed) ? unprefixed : undefined;
55
+ }
56
+ /**
57
+ * Keeps what is well formed and whose evidence names a file the change
58
+ * touches, in the reader's own order. The first three are offered, each with
59
+ * the question; the rest are held, in the same shape without it. Covered
60
+ * rules must name a promise the reader was given, and there are none when the
61
+ * existing promises could not be read.
62
+ */
63
+ export function readOfferAnswer(answer, input) {
64
+ const changed = new Set(input.changedFiles);
65
+ const found = [];
66
+ const rules = new Set();
67
+ for (const raw of Array.isArray(answer?.offers) ? answer.offers : []) {
68
+ if (found.length >= MAX_RULES_READ)
69
+ break;
70
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
71
+ continue;
72
+ const entry = raw;
73
+ const rule = text(entry.rule, 240);
74
+ const beneficiary = text(entry.beneficiary, 200);
75
+ const trigger = text(entry.trigger, 300);
76
+ const passingExample = text(entry.passingExample, 500);
77
+ const failingExample = text(entry.failingExample, 500);
78
+ const evidence = [];
79
+ for (const item of Array.isArray(entry.evidence) ? entry.evidence : []) {
80
+ const record = (item ?? {});
81
+ const named = typeof record.file === "string" ? changedPath(record.file, changed) : undefined;
82
+ const shows = text(record.shows, 400);
83
+ if (named === undefined || shows === undefined)
84
+ continue;
85
+ if (evidence.some((kept) => kept.file === named))
86
+ continue;
87
+ evidence.push({ file: named, shows });
88
+ if (evidence.length === 5)
89
+ break;
90
+ }
91
+ const statedBy = entry.statedBy;
92
+ const confidence = entry.confidence;
93
+ if (rule === undefined ||
94
+ /[\n\r]/.test(rule) ||
95
+ beneficiary === undefined ||
96
+ trigger === undefined ||
97
+ passingExample === undefined ||
98
+ failingExample === undefined ||
99
+ // A rule with nothing in the change behind it is an opinion, not a finding.
100
+ evidence.length === 0 ||
101
+ (statedBy !== "commit" && statedBy !== "test" && statedBy !== "code") ||
102
+ (confidence !== "high" && confidence !== "medium") ||
103
+ rules.has(rule.toLowerCase()))
104
+ continue;
105
+ rules.add(rule.toLowerCase());
106
+ found.push({
107
+ rule,
108
+ beneficiary,
109
+ trigger,
110
+ passingExample,
111
+ failingExample,
112
+ evidence,
113
+ statedBy,
114
+ confidence,
115
+ });
116
+ }
117
+ const covered = [];
118
+ if (input.existingKnown)
119
+ for (const item of Array.isArray(answer?.covered) ? answer.covered : []) {
120
+ const record = (item ?? {});
121
+ const promiseId = text(record.promiseId, 128);
122
+ const rule = text(record.rule, 300);
123
+ if (promiseId === undefined || rule === undefined)
124
+ continue;
125
+ if (!PROMISE_ID.test(promiseId) || !input.existingIds.has(promiseId))
126
+ continue;
127
+ if (covered.some((kept) => kept.promiseId === promiseId && kept.rule === rule))
128
+ continue;
129
+ covered.push({ promiseId, rule });
130
+ if (covered.length === 10)
131
+ break;
132
+ }
133
+ const notes = typeof answer?.notes === "string" ? answer.notes.trim().slice(0, 600) : "";
134
+ return {
135
+ offers: found
136
+ .slice(0, MAX_OFFERS)
137
+ .map((rule) => ({ ...rule, question: OFFER_QUESTION })),
138
+ held: found.slice(MAX_OFFERS),
139
+ covered,
140
+ notes,
141
+ };
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // What a person or a session reads.
145
+ function flat(value, limit) {
146
+ const line = value.replace(/\s+/g, " ").trim();
147
+ return line.length <= limit ? line : `${line.slice(0, limit - 3).trimEnd()}...`;
148
+ }
149
+ function sentence(value) {
150
+ const line = value.trim();
151
+ return /[.!?]$/.test(line) ? line : `${line}.`;
152
+ }
153
+ /** What in the change shows a rule, as the lines say it. */
154
+ function shownBy(rule) {
155
+ const first = rule.evidence[0];
156
+ const more = rule.evidence.length - 1;
157
+ return `Shown by ${first.file}: ${flat(first.shows, 180).replace(/[.!?]+$/, "")}${more > 0 ? `, and ${more} more file${more === 1 ? "" : "s"}` : ""}.`;
158
+ }
159
+ /**
160
+ * The lines handed to the pushing session, through the hook and as the
161
+ * command's `question`: each rule, what shows it, and the exact question; then
162
+ * what the session may do with them, stated once. Nothing when there is no
163
+ * offer, so a push with nothing to offer hears nothing about it.
164
+ */
165
+ export function offerLines(result) {
166
+ if (result.offers.length === 0)
167
+ return [];
168
+ const count = result.offers.length;
169
+ const lines = [
170
+ `Separately from the push, Balladeer read this change for rules no promise records yet and has ${count === 1 ? "one" : count} to offer:`,
171
+ ];
172
+ result.offers.forEach((offer, index) => {
173
+ lines.push(`${index + 1}. ${sentence(offer.rule)} ${shownBy(offer)} ${offer.question}`);
174
+ });
175
+ lines.push(OFFER_INSTRUCTIONS);
176
+ return lines;
177
+ }
178
+ function sourceName(source, count, directory) {
179
+ const promises = `${count} promise${count === 1 ? "" : "s"}`;
180
+ if (source === "connection")
181
+ return `${promises} read through this repository's connection`;
182
+ if (source === "directory")
183
+ return `${promises} from ${directory ?? "the directory named"}`;
184
+ return `${promises} sealed or mapped in this checkout`;
185
+ }
186
+ /**
187
+ * What a person reads at a terminal: one line per offer, what an existing
188
+ * promise already covers and which, and one sentence saying nothing was
189
+ * recorded. The git pre-push hook prints the same, since whoever ran git
190
+ * reads it.
191
+ */
192
+ export function personLines(result, options = {}) {
193
+ if (result.offers.length === 0 && options.quietWhenEmpty === true)
194
+ return [];
195
+ const lines = [];
196
+ if (result.offers.length === 0)
197
+ lines.push("Balladeer found no rule in this change that a promise does not already record.");
198
+ else {
199
+ lines.push("Balladeer read this change for rules no promise records yet:");
200
+ result.offers.forEach((offer, index) => {
201
+ lines.push(`${index + 1}. ${sentence(offer.rule)} ${shownBy(offer)}`);
202
+ });
203
+ if (result.held.length > 0)
204
+ lines.push(`${result.held.length} more ${result.held.length === 1 ? "was" : "were"} found and left out of this list; --json shows ${result.held.length === 1 ? "it" : "them"} under held.`);
205
+ }
206
+ for (const covered of result.covered)
207
+ lines.push(`Already covered by ${covered.promiseId}: ${sentence(flat(covered.rule, 240))}`);
208
+ lines.push(result.existing.known
209
+ ? `Compared against ${sourceName(result.existing.source, result.existing.count, options.directory)}.`
210
+ : "The promises already recorded could not be read, so none of this was compared against them.");
211
+ lines.push(result.offers.length === 0
212
+ ? "Nothing was recorded."
213
+ : "Nothing was recorded; to keep one, ask your coding agent to record it as a promise for your review.");
214
+ return lines;
215
+ }
216
+ const meaningText = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
217
+ /** One promise as the reader is told it: title, outcome, who, when, and what it forbids. */
218
+ export function existingFromDocument(document) {
219
+ const meaning = document.meaning;
220
+ const outcome = meaningText(meaning.oneSentenceOutcome) ?? meaningText(meaning.observableOutcome);
221
+ const beneficiary = meaningText(meaning.beneficiary);
222
+ const trigger = meaningText(meaning.trigger);
223
+ const failing = meaning.failingExamples
224
+ .map((example) => meaningText(example.label) ?? flat(example.expectedOutcome, 80))
225
+ .filter((label) => label !== "");
226
+ return {
227
+ promiseId: document.promiseId,
228
+ title: document.title,
229
+ ...(outcome === undefined ? {} : { outcome }),
230
+ ...(beneficiary === undefined ? {} : { beneficiary }),
231
+ ...(trigger === undefined ? {} : { trigger }),
232
+ ...(failing.length === 0 ? {} : { failingExamples: failing }),
233
+ };
234
+ }
235
+ /**
236
+ * Every promise in a directory of promise files, or in one file.
237
+ *
238
+ * Each `*.json` holds one promise in `get_promise`'s shape or a sealed
239
+ * package's, read with `readPromiseFile`, or an object with a `promises` array
240
+ * of them, which is how the evaluation's promise sets are laid out. A file
241
+ * holding neither is skipped and named.
242
+ */
243
+ export function readPromisesDirectory(path) {
244
+ let files;
245
+ try {
246
+ const stat = statSync(path);
247
+ if (stat.isFile())
248
+ files = [path];
249
+ else if (stat.isDirectory())
250
+ files = readdirSync(path)
251
+ .filter((name) => name.endsWith(".json"))
252
+ .sort()
253
+ .map((name) => join(path, name));
254
+ else
255
+ return { ok: false, reason: `${path} is neither a directory nor a file` };
256
+ }
257
+ catch {
258
+ return { ok: false, reason: `${path} could not be read` };
259
+ }
260
+ const promises = new Map();
261
+ const skipped = [];
262
+ for (const file of files) {
263
+ const single = readPromiseFile(file);
264
+ if (single.ok) {
265
+ if (!promises.has(single.promise.promiseId))
266
+ promises.set(single.promise.promiseId, single.promise);
267
+ continue;
268
+ }
269
+ let listed;
270
+ try {
271
+ listed = JSON.parse(readFileSync(file, "utf8"))?.promises;
272
+ }
273
+ catch {
274
+ listed = undefined;
275
+ }
276
+ if (!Array.isArray(listed)) {
277
+ skipped.push(file);
278
+ continue;
279
+ }
280
+ for (const entry of listed) {
281
+ const read = normalizePromise(entry);
282
+ if (read.ok && !promises.has(read.promise.promiseId))
283
+ promises.set(read.promise.promiseId, read.promise);
284
+ }
285
+ }
286
+ return { ok: true, promises: [...promises.values()], skipped };
287
+ }
288
+ async function inBatches(items, size, work) {
289
+ const out = [];
290
+ for (let at = 0; at < items.length; at += size)
291
+ out.push(...(await Promise.all(items.slice(at, at + size).map(work))));
292
+ return out;
293
+ }
294
+ /**
295
+ * Every agreed promise through the connection: the ids with `list_promises`,
296
+ * page after page, then each meaning with `get_promise`, which the reader
297
+ * always marks unattended. Any read that fails fails the whole source, so a
298
+ * partial list is never presented as the repository's promises.
299
+ */
300
+ export async function readThroughConnection(reader) {
301
+ const listed = await reader.list({ max: MAX_EXISTING_PROMISES });
302
+ if (!listed.ok)
303
+ return { ok: false, reason: `listing its promises failed: ${listed.reason}` };
304
+ const ids = listed.ids.slice(0, MAX_EXISTING_PROMISES);
305
+ const reads = await inBatches(ids, 8, (id) => reader.get(id));
306
+ const failed = reads.flatMap((read, index) => (read.ok ? [] : [{ id: ids[index], read }]));
307
+ if (failed.length > 0) {
308
+ const first = failed[0];
309
+ return {
310
+ ok: false,
311
+ reason: `${failed.length} of ${ids.length} promises could not be read through it (${first.id}: ${flat(first.read.ok ? "" : first.read.reason, 200)})`,
312
+ };
313
+ }
314
+ return {
315
+ ok: true,
316
+ promises: reads.flatMap((read) => (read.ok ? [read.promise] : [])),
317
+ capped: listed.more === true || listed.ids.length > MAX_EXISTING_PROMISES,
318
+ };
319
+ }
320
+ /** The promises this checkout holds: sealed packages first, then behavior-map excerpts. */
321
+ export function readCheckoutPromises(root) {
322
+ const found = new Map();
323
+ const packages = join(root, ".continuity", "packages");
324
+ let names = [];
325
+ try {
326
+ names = readdirSync(packages)
327
+ .filter((name) => name.endsWith(".json"))
328
+ .sort();
329
+ }
330
+ catch {
331
+ names = [];
332
+ }
333
+ for (const name of names) {
334
+ const read = readPromiseFile(join(packages, name));
335
+ if (read.ok)
336
+ found.set(read.promise.promiseId, existingFromDocument(read.promise));
337
+ }
338
+ for (const map of readBehaviorMaps(join(root, ".continuity", "behavior-maps")).maps) {
339
+ if (found.has(map.promiseId) || map.meaning === undefined)
340
+ continue;
341
+ const failing = map.meaning.failingExamples.map((example) => {
342
+ const colon = example.text.indexOf(": ");
343
+ return flat(colon > 0 ? example.text.slice(0, colon) : example.text, 80);
344
+ });
345
+ found.set(map.promiseId, {
346
+ promiseId: map.promiseId,
347
+ title: map.meaning.title,
348
+ outcome: map.meaning.observableOutcome,
349
+ ...(failing.length === 0 ? {} : { failingExamples: failing }),
350
+ });
351
+ }
352
+ return [...found.values()];
353
+ }
354
+ /**
355
+ * The promises already recorded, from the first source that answers: a
356
+ * directory named by the caller; else this repository's agent connection;
357
+ * else what the checkout holds. A connection that is missing or fails in any
358
+ * read falls through to the checkout, and the notes say why.
359
+ */
360
+ export async function readExistingPromises(input) {
361
+ const notes = [];
362
+ if (input.directory !== undefined)
363
+ return {
364
+ known: true,
365
+ source: "directory",
366
+ promises: input.directory.map(existingFromDocument),
367
+ notes,
368
+ };
369
+ let reader;
370
+ if (input.reader !== undefined && input.reader !== null)
371
+ reader = input.reader;
372
+ else if (input.reader === undefined) {
373
+ const connection = connectionReader({
374
+ environment: input.environment,
375
+ controlPlane: input.controlPlane,
376
+ cwd: input.root,
377
+ });
378
+ if (connection.ok)
379
+ reader = connection.reader;
380
+ }
381
+ if (reader !== undefined) {
382
+ const read = await readThroughConnection(reader);
383
+ if (read.ok) {
384
+ if (read.capped)
385
+ notes.push(`Only the first ${MAX_EXISTING_PROMISES} promises this repository's connection lists were compared.`);
386
+ return {
387
+ known: true,
388
+ source: "connection",
389
+ promises: read.promises.map(existingFromDocument),
390
+ notes,
391
+ };
392
+ }
393
+ notes.push(`This repository's connection could not be used, so the checkout's promises were used instead: ${read.reason}.`);
394
+ }
395
+ const checkout = readCheckoutPromises(input.root);
396
+ if (checkout.length > 0) {
397
+ notes.push("Only the promises sealed or mapped in this checkout were compared; others may be recorded.");
398
+ return { known: true, source: "checkout", promises: checkout, notes };
399
+ }
400
+ return { known: false, source: "none", promises: [], notes };
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // The change.
404
+ const RAW_DIFF_BYTES = 4 * 1024 * 1024;
405
+ /** `git diff --no-color <base> <head>`, bounded as the prompt carries it. */
406
+ export async function readChangeDiff(root, base, head) {
407
+ const run = await runOwnedProcess({
408
+ command: "git",
409
+ args: ["-C", root, "diff", "--no-color", "--no-ext-diff", base, head],
410
+ cwd: root,
411
+ env: process.env,
412
+ timeoutMs: 60_000,
413
+ maxOutputBytes: RAW_DIFF_BYTES,
414
+ });
415
+ return boundDiff(run.stdout, { base, head, totalIsFloor: run.bounded });
416
+ }
417
+ /** The change's commit messages, oldest first, with trailers that carry an address removed. */
418
+ export async function readCommitMessages(root, base, head) {
419
+ const run = await runOwnedProcess({
420
+ command: "git",
421
+ args: ["-C", root, "log", "--no-color", "--reverse", "--format=%B%x00", `${base}..${head}`],
422
+ cwd: root,
423
+ env: process.env,
424
+ timeoutMs: 30_000,
425
+ maxOutputBytes: 256 * 1024,
426
+ });
427
+ return run.stdout
428
+ .split("\0")
429
+ .map((message) => message
430
+ .split("\n")
431
+ .filter((line) => !/^[A-Za-z][A-Za-z-]*:\s.*<[^>]*@[^>]*>\s*$/.test(line.trim()))
432
+ .join("\n")
433
+ .trim())
434
+ .filter((message) => message !== "")
435
+ .join("\n---\n");
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // One reading.
439
+ /** Tools the reader has: reading files and git's read commands, nothing that writes. */
440
+ const READING_TOOLS = ["Read", "Grep", "Glob", "Bash"];
441
+ const READING_RULES = [
442
+ "Read",
443
+ "Grep",
444
+ "Glob",
445
+ "Bash(git diff:*)",
446
+ "Bash(git log:*)",
447
+ "Bash(git show:*)",
448
+ ];
449
+ const REFUSED_RULES = ["Bash(git push:*)", "Bash(gh:*)", "Edit", "Write"];
450
+ function failureMessage(run) {
451
+ switch (run.reason) {
452
+ case "no_verified_login":
453
+ return "no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine";
454
+ case "timed_out":
455
+ return "the reader was still reading when its time ran out";
456
+ case "aborted":
457
+ return "the reading was stopped";
458
+ case "spawn_failed":
459
+ return `${run.client === "none" ? "the coding agent" : run.client} could not be started`;
460
+ case "no_json":
461
+ case "no_answer":
462
+ return "the reader's answer was not the JSON it was asked for";
463
+ default:
464
+ return `the reader stopped without an answer (${run.reason ?? "unknown"})`;
465
+ }
466
+ }
467
+ /** Read one change for rules no promise records. Never throws for the agent or the machine. */
468
+ export async function readOffers(input) {
469
+ const diff = await readChangeDiff(input.root, input.base, input.head);
470
+ const commitMessages = await readCommitMessages(input.root, input.base, input.head);
471
+ const prompt = offerPrompt({
472
+ base: input.base,
473
+ head: input.head,
474
+ changedFiles: input.files,
475
+ diff,
476
+ commitMessages,
477
+ existing: input.existing.promises,
478
+ existingKnown: input.existing.known,
479
+ minutes: Math.max(1, Math.floor(input.timeoutMs / 60_000)),
480
+ });
481
+ const run = await (input.runAgent ?? runHeadlessAgent)({
482
+ client: input.client,
483
+ ...(input.prefer === undefined ? {} : { prefer: input.prefer }),
484
+ cwd: input.root,
485
+ prompt,
486
+ tools: READING_TOOLS,
487
+ allowedTools: READING_RULES,
488
+ disallowedTools: REFUSED_RULES,
489
+ sandbox: "read-only",
490
+ timeoutMs: input.timeoutMs,
491
+ env: input.environment,
492
+ jsonOutput: true,
493
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
494
+ });
495
+ if (!run.ok || run.client === "none")
496
+ return { ok: false, reason: run.reason ?? "no_answer", message: failureMessage(run) };
497
+ const read = readOfferAnswer(run.json, {
498
+ changedFiles: input.files,
499
+ existingIds: new Set(input.existing.promises.map((promise) => promise.promiseId)),
500
+ existingKnown: input.existing.known,
501
+ });
502
+ const notes = [
503
+ ...input.existing.notes,
504
+ ...(diff.cutLine === undefined
505
+ ? []
506
+ : [
507
+ `The reader was handed ${grouped(diff.shownCharacters)} of ${diff.totalIsFloor ? "more than " : ""}${grouped(diff.totalCharacters)} characters of the diff.`,
508
+ ]),
509
+ ...(read.notes === "" ? [] : [read.notes]),
510
+ ];
511
+ return {
512
+ ok: true,
513
+ result: {
514
+ schemaVersion: OFFERS_SCHEMA_VERSION,
515
+ brief: OFFER_BRIEF_REVISION,
516
+ base: input.base,
517
+ head: input.head,
518
+ offers: read.offers,
519
+ held: read.held,
520
+ covered: read.covered,
521
+ existing: {
522
+ known: input.existing.known,
523
+ count: input.existing.promises.length,
524
+ source: input.existing.source,
525
+ },
526
+ notes: notes.join(" "),
527
+ agent: {
528
+ client: run.client,
529
+ model: run.model ?? "",
530
+ durationMs: run.durationMs,
531
+ inputTokens: run.inputTokens ?? 0,
532
+ outputTokens: run.outputTokens ?? 0,
533
+ },
534
+ },
535
+ };
536
+ }
537
+ /**
538
+ * Whether a change repairs something, read from what its author wrote: a
539
+ * commit whose subject is a fix, a hotfix or a revert by the usual convention,
540
+ * or says in words that it fixes, restores or reverts, or names a regression.
541
+ *
542
+ * The first measurement (29 September 2026, 24 real regressions) is why this
543
+ * is the default gate. Read at the fix, a change offered the rule that had
544
+ * broken in 20 cases of 24, and as its first offer in 18. Read at the change
545
+ * that first built the behavior, it offered that rule in 3 of 23, while
546
+ * offering two rules or more on almost every feature. A repair is where a rule
547
+ * has just shown what losing it costs; everywhere else the reader is mostly
548
+ * guessing which of many rules will matter, and the person is asked too often.
549
+ * No model is asked: the subject line is what the author said.
550
+ */
551
+ export function isRepairChange(messages) {
552
+ const subjects = messages
553
+ .split("\n---\n")
554
+ .map((message) => message.split("\n")[0]?.trim() ?? "")
555
+ .filter((subject) => subject !== "");
556
+ return subjects.some((subject) => /^(fix|hotfix|revert)(\([^)]*\))?!?:/i.test(subject) ||
557
+ /^revert\b/i.test(subject) ||
558
+ /\b(fix(es|ed)?|hotfix(es)?|regression|regressed|restore[sd]?|revert(s|ed)?)\b/i.test(subject));
559
+ }
560
+ /** The first `max` offers stay offers; the rest join what was held, without their question. */
561
+ export function limitOffers(result, max) {
562
+ if (result.offers.length <= max)
563
+ return result;
564
+ const over = result.offers.slice(max).map((offer) => ({
565
+ rule: offer.rule,
566
+ beneficiary: offer.beneficiary,
567
+ trigger: offer.trigger,
568
+ passingExample: offer.passingExample,
569
+ failingExample: offer.failingExample,
570
+ evidence: offer.evidence,
571
+ statedBy: offer.statedBy,
572
+ confidence: offer.confidence,
573
+ }));
574
+ return { ...result, offers: result.offers.slice(0, max), held: [...over, ...result.held] };
575
+ }
576
+ /**
577
+ * The same reading, for the push hook: the change is already identified, and
578
+ * what comes back is the lines the pushing session or person is handed. Any
579
+ * failure, a missing login or a reading still running at the deadline is no
580
+ * lines at all: finding rules never holds or fails a push.
581
+ */
582
+ export async function offersForPush(input) {
583
+ const now = input.now ?? Date.now;
584
+ try {
585
+ if (input.files.length === 0)
586
+ return [];
587
+ // Decided before anything is read: a push that repairs nothing costs no
588
+ // reading, no tokens and no question.
589
+ if (input.policy === "fixes" &&
590
+ !isRepairChange(await readCommitMessages(input.root, input.base, input.head)))
591
+ return [];
592
+ const existing = await readExistingPromises({
593
+ root: input.root,
594
+ ...(input.reader === undefined ? {} : { reader: input.reader }),
595
+ environment: input.environment,
596
+ controlPlane: input.controlPlane,
597
+ });
598
+ const remaining = input.deadline - now();
599
+ if (remaining < 1000 || input.signal.aborted)
600
+ return [];
601
+ const reading = await readOffers({
602
+ root: input.root,
603
+ base: input.base,
604
+ head: input.head,
605
+ files: input.files,
606
+ existing,
607
+ client: input.client,
608
+ ...(input.host === "git" ? {} : { prefer: input.host }),
609
+ timeoutMs: remaining,
610
+ environment: input.environment,
611
+ signal: input.signal,
612
+ ...(input.runAgent === undefined ? {} : { runAgent: input.runAgent }),
613
+ });
614
+ if (!reading.ok || input.signal.aborted)
615
+ return [];
616
+ const handed = limitOffers(reading.result, input.max ?? MAX_OFFERS);
617
+ return input.host === "git"
618
+ ? personLines(handed, { quietWhenEmpty: true })
619
+ : offerLines(handed);
620
+ }
621
+ catch {
622
+ return [];
623
+ }
624
+ }
625
+ export const OFFERS_USAGE = ` ${CLI_INVOCATION} offers [--base <ref>] [--head <ref>] [--json] [--repo <root>]
626
+ [--promises-dir <dir>] [--client auto|claude|codex] [--timeout <seconds>]
627
+ Read this change for rules it establishes that no promise records yet,
628
+ on your own Claude Code or Codex login, and list at most three, each with
629
+ the files in the change that show it. It records nothing and stores
630
+ nothing: to keep one, ask your coding agent to record it as a promise for
631
+ your review. The promises already recorded come from --promises-dir,
632
+ else this repository's Balladeer connection, else the promises sealed or
633
+ mapped in this checkout. The change runs from the fork point with the
634
+ remote default branch to HEAD unless named. --json prints the result.
635
+ `;
636
+ export function parseOffersArguments(argv) {
637
+ const args = [...argv];
638
+ let base;
639
+ let head;
640
+ let json = false;
641
+ let repo;
642
+ let promisesDir;
643
+ let client = "auto";
644
+ let timeoutSeconds = DEFAULT_OFFER_TIMEOUT_SECONDS;
645
+ const value = (flag, inline) => {
646
+ const next = inline ?? args.shift();
647
+ if (next === undefined || next === "")
648
+ throw new StoreError("usage", `${flag} needs a value.`);
649
+ return next;
650
+ };
651
+ while (args.length > 0) {
652
+ const arg = args.shift();
653
+ if (!arg.startsWith("--"))
654
+ throw new StoreError("usage", `offers takes no bare arguments: ${arg}.`);
655
+ const [name, ...rest] = arg.split("=");
656
+ const inline = rest.length > 0 ? rest.join("=") : undefined;
657
+ if (name === "--base")
658
+ base = value(name, inline);
659
+ else if (name === "--head")
660
+ head = value(name, inline);
661
+ else if (name === "--json")
662
+ json = true;
663
+ else if (name === "--repo")
664
+ repo = value(name, inline);
665
+ else if (name === "--promises-dir")
666
+ promisesDir = 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 if (name === "--timeout") {
674
+ const seconds = Number(value(name, inline));
675
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 3600)
676
+ throw new StoreError("usage", "--timeout needs a number of seconds from 1 to 3600.");
677
+ timeoutSeconds = seconds;
678
+ }
679
+ else
680
+ throw new StoreError("usage", `Unknown option ${arg} for offers.`);
681
+ }
682
+ return {
683
+ ...(base === undefined ? {} : { base }),
684
+ ...(head === undefined ? {} : { head }),
685
+ json,
686
+ ...(repo === undefined ? {} : { repo }),
687
+ ...(promisesDir === undefined ? {} : { promisesDir }),
688
+ client,
689
+ timeoutSeconds,
690
+ };
691
+ }
692
+ /** `balladeer offers`, from arguments already parsed. */
693
+ export async function runOffers(options) {
694
+ const { args } = options;
695
+ const deps = options.deps ?? {};
696
+ const step = (fields) => {
697
+ if (args.json)
698
+ options.write(`${JSON.stringify(fields)}\n`);
699
+ else if (fields.exitCode === 0)
700
+ options.write(`${fields.message}\n`);
701
+ else
702
+ options.error(`${fields.message}\n`);
703
+ return fields.exitCode;
704
+ };
705
+ const refuse = (reason, message) => {
706
+ if (args.json)
707
+ options.write(`${JSON.stringify({ step: "error", reason, message, changed: false, exitCode: 4 })}\n`);
708
+ else
709
+ options.error(`${message}\n`);
710
+ return 4;
711
+ };
712
+ // A judge or a reader's own agent runs with this set; reading offers from
713
+ // inside one would start another reading all over again.
714
+ if (options.environment[JUDGE_ACTIVE_VARIABLE] === "1")
715
+ return refuse("nested_run", "Balladeer does not read a change for offers from inside its own judge or reader runs.");
716
+ const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(options.cwd);
717
+ if (root === undefined || (await repositoryRoot(root)) === undefined)
718
+ return refuse("not_a_repository", "This is not a git repository, so there is no change to read.");
719
+ const head = await resolveCommit(root, args.head ?? "HEAD");
720
+ if (head === undefined)
721
+ return refuse("no_head", `${args.head ?? "HEAD"} is not a commit here, so nothing was read.`);
722
+ const base = args.base !== undefined ? await resolveCommit(root, args.base) : await defaultBase(root, head);
723
+ if (base === undefined)
724
+ return refuse("no_base", `${args.base ?? "The base"} is not a commit here, so nothing was read.`);
725
+ let directory;
726
+ let directoryNote;
727
+ if (args.promisesDir !== undefined) {
728
+ const path = resolve(options.cwd, args.promisesDir);
729
+ const read = readPromisesDirectory(path);
730
+ if (!read.ok)
731
+ return refuse("promises_unreadable", `--promises-dir: ${read.reason}.`);
732
+ directory = read.promises;
733
+ if (read.skipped.length > 0)
734
+ directoryNote = `${read.skipped.length} file${read.skipped.length === 1 ? "" : "s"} in ${args.promisesDir} held no promise and ${read.skipped.length === 1 ? "was" : "were"} skipped.`;
735
+ }
736
+ const files = base === head ? [] : await changedFiles(root, base, head);
737
+ if (files.length === 0)
738
+ return step({
739
+ step: "offers",
740
+ status: "nothing_to_read",
741
+ reason: "empty_change",
742
+ message: "Nothing to read: the change is empty. Nothing was recorded.",
743
+ exitCode: 0,
744
+ });
745
+ const existing = await readExistingPromises({
746
+ root,
747
+ ...(directory === undefined ? {} : { directory }),
748
+ ...(deps.reader === undefined ? {} : { reader: deps.reader }),
749
+ environment: options.environment,
750
+ controlPlane: options.controlPlane,
751
+ });
752
+ const reading = await readOffers({
753
+ root,
754
+ base,
755
+ head,
756
+ files,
757
+ existing: directoryNote === undefined
758
+ ? existing
759
+ : { ...existing, notes: [directoryNote, ...existing.notes] },
760
+ client: args.client,
761
+ timeoutMs: args.timeoutSeconds * 1000,
762
+ environment: options.environment,
763
+ ...(deps.runAgent === undefined ? {} : { runAgent: deps.runAgent }),
764
+ });
765
+ if (!reading.ok) {
766
+ const exitCode = reading.reason === "no_verified_login" ? 3 : 5;
767
+ return step({
768
+ step: "offers",
769
+ status: "not_read",
770
+ reason: reading.reason,
771
+ message: `No rules were read from this change: ${reading.message}. Nothing was recorded.`,
772
+ exitCode,
773
+ });
774
+ }
775
+ if (args.json)
776
+ options.write(`${JSON.stringify(reading.result)}\n`);
777
+ else
778
+ options.write(`${personLines(reading.result, args.promisesDir === undefined ? {} : { directory: args.promisesDir }).join("\n")}\n`);
779
+ return 0;
780
+ }
781
+ /** `balladeer offers ...` from raw arguments, as `cli.ts` hands them over. */
782
+ export async function offersCommand(argv, io) {
783
+ let args;
784
+ let controlPlane;
785
+ try {
786
+ args = parseOffersArguments(argv);
787
+ controlPlane = normalizeControlPlane(io.environment.BALLADEER_CONTROL_PLANE?.trim() || DEFAULT_CONTROL_PLANE);
788
+ }
789
+ catch (error) {
790
+ io.error(`${error instanceof Error ? error.message : String(error)}\n\n${OFFERS_USAGE}`);
791
+ return 4;
792
+ }
793
+ return runOffers({
794
+ args,
795
+ cwd: io.cwd,
796
+ environment: io.environment,
797
+ controlPlane,
798
+ write: io.write,
799
+ error: io.error,
800
+ });
801
+ }