balladeer 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/anchor-budget.d.ts +40 -0
  2. package/dist/anchor-budget.js +110 -0
  3. package/dist/anchoring.d.ts +241 -0
  4. package/dist/anchoring.js +766 -0
  5. package/dist/anchors-client.d.ts +62 -0
  6. package/dist/anchors-client.js +197 -0
  7. package/dist/anchors-schema.d.ts +85 -0
  8. package/dist/anchors-schema.js +246 -0
  9. package/dist/cli.d.ts +1 -1
  10. package/dist/cli.js +24 -7
  11. package/dist/commands/anchor-usage.d.ts +6 -0
  12. package/dist/commands/anchor-usage.js +19 -0
  13. package/dist/commands/anchor.d.ts +127 -0
  14. package/dist/commands/anchor.js +303 -0
  15. package/dist/commands/guidance.d.ts +2 -1
  16. package/dist/commands/guidance.js +55 -0
  17. package/dist/commands/judge.d.ts +219 -12
  18. package/dist/commands/judge.js +911 -103
  19. package/dist/commands/map.d.ts +46 -2
  20. package/dist/commands/map.js +244 -5
  21. package/dist/commands/mcp.js +2 -2
  22. package/dist/guidance-hook.mjs +282 -95
  23. package/dist/guidance.d.ts +7 -0
  24. package/dist/guidance.js +10 -1
  25. package/dist/headless-agent.d.ts +19 -1
  26. package/dist/headless-agent.js +22 -5
  27. package/dist/hook-trust.d.ts +41 -9
  28. package/dist/hook-trust.js +98 -16
  29. package/dist/judge-brief.d.ts +33 -3
  30. package/dist/judge-brief.js +39 -2
  31. package/dist/judge-hook.d.ts +188 -14
  32. package/dist/judge-hook.js +919 -61
  33. package/dist/judge-said.d.ts +52 -0
  34. package/dist/judge-said.js +181 -0
  35. package/dist/promise-meaning.d.ts +25 -0
  36. package/dist/promise-meaning.js +24 -7
  37. package/dist/relay.d.ts +71 -0
  38. package/dist/relay.js +193 -0
  39. package/dist/remove-earlier.js +4 -2
  40. package/dist/risk/git.d.ts +3 -1
  41. package/dist/risk/git.js +3 -3
  42. package/dist/risk/graph-cache.d.ts +83 -0
  43. package/dist/risk/graph-cache.js +291 -0
  44. package/dist/risk/import-graph.d.ts +43 -0
  45. package/dist/risk/import-graph.js +88 -34
  46. package/dist/risk/index.d.ts +1 -1
  47. package/dist/risk/index.js +1 -1
  48. package/dist/risk/pipeline.d.ts +13 -0
  49. package/dist/risk/pipeline.js +15 -4
  50. package/dist/risk/score.d.ts +10 -0
  51. package/dist/risk/score.js +11 -2
  52. package/dist/scratch-worktree.d.ts +5 -1
  53. package/dist/scratch-worktree.js +9 -2
  54. package/dist/user-scope.d.ts +74 -3
  55. package/dist/user-scope.js +270 -9
  56. package/dist/wire.d.ts +19 -3
  57. package/dist/wire.js +2 -2
  58. package/package.json +7 -2
@@ -0,0 +1,766 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ANCHOR_BRIEF_SOURCE, ANCHOR_LIMITS, BEHAVIOR_MAP_SOURCE, PROMISE_ANCHORS_SCHEMA_VERSION, anchorFiles, checkPromiseAnchors, isAnchorPath, isAnchorResourceName, isAnchorSymbol, } from "./anchors-schema.js";
5
+ import { ENTRY_POINT_KINDS } from "./behavior-map-schema.js";
6
+ import { runHeadlessAgent, } from "./headless-agent.js";
7
+ import { isIndexSource } from "./risk/graph-cache.js";
8
+ import { isTestPath } from "./risk/paths.js";
9
+ import { buildIdf, identifierTokens, words } from "./risk/text.js";
10
+ /**
11
+ * Promise anchors on the laptop: where an agreed promise's behavior lives,
12
+ * written by one small call on the person's own Claude Code or Codex, or cut
13
+ * from a behavior map, and shared through the server with every teammate.
14
+ *
15
+ * The call is `anchor-brief/v3`, frozen on 1 October 2026 after the
16
+ * measurement in `docs/agentic-testing/evidence/20261001/anchors-measurement.md`.
17
+ * The index, the shortlist and the brief below are
18
+ * `scripts/agentic-eval/anchors-fresh.ts` at v3, word for word. They were tuned
19
+ * on the seventeen in-window cases, so any change to them is a new revision
20
+ * with a label of its own, measured before it ships; never edit v3 in place.
21
+ */
22
+ export const ANCHOR_BRIEF_REVISION = ANCHOR_BRIEF_SOURCE;
23
+ const FILES = 150;
24
+ const NAMES_PER_FILE = 12;
25
+ const PER_FOLDER = 6;
26
+ const RARE_WORDS = 10;
27
+ const PER_RARE_WORD = 3;
28
+ /**
29
+ * Each anchoring call's caps. The measured calls wrote at most 1,157 output
30
+ * tokens and took at most 9.3 seconds; one runaway call in 291 wrote 128,690.
31
+ * A call that reaches either cap leaves its promise unanchored.
32
+ */
33
+ export const ANCHOR_CALL_CAPS = { outputTokens: 8000, wallMs: 60_000 };
34
+ /** A call is not started with less time than this left: the measured calls took up to 9.3 seconds. */
35
+ const MIN_CALL_TIME_MS = 15_000;
36
+ /** Every non-test source file the graph knows, with the top-level names it exports. */
37
+ export function anchorIndex(repository) {
38
+ const sources = repository.graph.files.filter(isIndexSource);
39
+ repository.prepareNames(sources);
40
+ const entries = [];
41
+ for (const file of sources) {
42
+ const names = repository.exportedNames(file);
43
+ if (names === undefined)
44
+ continue;
45
+ const pathWords = file
46
+ .replace(/\.[^.]+$/, "")
47
+ .split(/[/._-]+/)
48
+ .flatMap((part) => identifierTokens(part));
49
+ const nameWords = names.flatMap((entry) => identifierTokens(entry.name));
50
+ entries.push({ file, names, words: words([...pathWords, ...nameWords].join(" ")) });
51
+ }
52
+ return { sha: repository.sha, entries, idf: buildIdf(entries.map((entry) => entry.words)) };
53
+ }
54
+ /**
55
+ * The promise as the measured brief showed it: title, outcome, the cases'
56
+ * label, setup and outcome, and the non-goals. Nothing else of the meaning is
57
+ * read, so what the model sees is what was measured.
58
+ */
59
+ export function briefMeaning(meaning) {
60
+ const example = (entry) => ({
61
+ ...(entry.label ? { label: entry.label } : {}),
62
+ setup: entry.setup,
63
+ expectedOutcome: entry.expectedOutcome,
64
+ });
65
+ return {
66
+ title: meaning.title,
67
+ observableOutcome: meaning.observableOutcome,
68
+ passingExamples: meaning.passingExamples.map(example),
69
+ failingExamples: meaning.failingExamples.map(example),
70
+ nonGoals: [...meaning.nonGoals],
71
+ };
72
+ }
73
+ function promiseText(meaning) {
74
+ const examples = [...(meaning.passingExamples ?? []), ...(meaning.failingExamples ?? [])]
75
+ .map((example) => [example.label, example.setup, example.expectedOutcome].filter(Boolean).join(" "))
76
+ .join(" ");
77
+ return [meaning.title, meaning.observableOutcome, examples, ...(meaning.nonGoals ?? [])].join(" ");
78
+ }
79
+ /** The files whose path and names share the most weighted words with the promise. */
80
+ export function shortlist(index, meaning) {
81
+ const promiseWords = words(promiseText(meaning));
82
+ const scored = index.entries.map((entry) => {
83
+ let score = 0;
84
+ for (const word of promiseWords)
85
+ if (entry.words.has(word))
86
+ score += index.idf.weight(word);
87
+ return { entry, score };
88
+ });
89
+ const ordered = scored
90
+ .filter((item) => item.score > 0)
91
+ .sort((a, b) => b.score - a.score || a.entry.file.localeCompare(b.entry.file));
92
+ // At most a few files per folder, so a promise whose words also name generic UI (table,
93
+ // dialog, row) still reaches the feature folders where it lives.
94
+ const perFolder = new Map();
95
+ const out = [];
96
+ // The promise's rarest words that name anything at all each get a few places first. A
97
+ // summed score favours files that match many common words (table, row, dialog) over the one
98
+ // file named by a rare word the promise uses for a specific place (SSO, PIN, verified domain).
99
+ const rare = [...promiseWords]
100
+ .map((word) => ({ word, weight: index.idf.weight(word) }))
101
+ .filter(({ word }) => index.entries.some((entry) => entry.words.has(word)))
102
+ .sort((a, b) => b.weight - a.weight)
103
+ .slice(0, RARE_WORDS);
104
+ const byScore = new Map(ordered.map((item) => [item.entry.file, item.score]));
105
+ for (const { word } of rare) {
106
+ const matches = index.entries
107
+ .filter((entry) => entry.words.has(word) && !out.includes(entry))
108
+ .sort((a, b) => (byScore.get(b.file) ?? 0) - (byScore.get(a.file) ?? 0))
109
+ .slice(0, PER_RARE_WORD);
110
+ out.push(...matches);
111
+ }
112
+ for (const entry of out) {
113
+ const folder = entry.file.slice(0, entry.file.lastIndexOf("/"));
114
+ perFolder.set(folder, (perFolder.get(folder) ?? 0) + 1);
115
+ }
116
+ for (const item of ordered) {
117
+ if (out.includes(item.entry))
118
+ continue;
119
+ if (out.length >= FILES)
120
+ break;
121
+ const folder = item.entry.file.slice(0, item.entry.file.lastIndexOf("/"));
122
+ const count = perFolder.get(folder) ?? 0;
123
+ if (count >= PER_FOLDER)
124
+ continue;
125
+ perFolder.set(folder, count + 1);
126
+ out.push(item.entry);
127
+ }
128
+ return out;
129
+ }
130
+ /** `anchor-brief/v3`, word for word. */
131
+ export function anchorBrief(meaning, candidates) {
132
+ const listing = candidates
133
+ .map((entry) => `${entry.file}: ${entry.names
134
+ .slice(0, NAMES_PER_FILE)
135
+ .map((name) => name.name)
136
+ .join(", ")}`)
137
+ .join("\n");
138
+ return [
139
+ "You are anchoring a product promise to the code that realizes it.",
140
+ "",
141
+ "The promise, in its owner's words:",
142
+ JSON.stringify({
143
+ title: meaning.title,
144
+ observableOutcome: meaning.observableOutcome,
145
+ passingExamples: meaning.passingExamples ?? [],
146
+ failingExamples: meaning.failingExamples ?? [],
147
+ nonGoals: meaning.nonGoals ?? [],
148
+ }, null, 1),
149
+ "",
150
+ "Source files of the repository, each with the names it exports (a pre-selected list, not the whole repository):",
151
+ listing,
152
+ "",
153
+ "Pick, from this list only:",
154
+ "- entryPoints: where the promised behavior starts (a page, route handler, API endpoint, command, job or exported function a caller uses), at most 6;",
155
+ "- symbols: the functions, components or classes whose code decides whether the promise holds, at most 12.",
156
+ "Prefer the code that decides the outcome over generic helpers. Use exact file paths and names from the list. If nothing in the list realizes the promise, answer empty lists.",
157
+ "Cover every layer the behavior passes through, as far as the list shows them: the page or route where a person meets it, the components that render it, the hooks or state that drive it, and the server or API code it calls. When the promise names several places (several pages or features), anchor each of them. That is usually four to ten files.",
158
+ 'Answer with JSON only: {"entryPoints":[{"file":"...","symbol":"...","kind":"route|command|ui|job|event|function"}],"symbols":[{"file":"...","name":"..."}]}',
159
+ ].join("\n");
160
+ }
161
+ /**
162
+ * The first complete JSON value in a model's answer, fenced or bare, as the
163
+ * measurement read it. Undefined when there is none.
164
+ */
165
+ export function firstJsonValue(answer) {
166
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(answer);
167
+ for (const body of fenced ? [fenced[1], answer] : [answer]) {
168
+ const start = body.search(/[[{]/);
169
+ if (start === -1)
170
+ continue;
171
+ const opener = body[start];
172
+ const closer = opener === "{" ? "}" : "]";
173
+ let depth = 0;
174
+ let inString = false;
175
+ let escaped = false;
176
+ for (let index = start; index < body.length; index += 1) {
177
+ const character = body[index];
178
+ if (escaped) {
179
+ escaped = false;
180
+ continue;
181
+ }
182
+ if (character === "\\") {
183
+ escaped = true;
184
+ continue;
185
+ }
186
+ if (character === '"')
187
+ inString = !inString;
188
+ if (inString)
189
+ continue;
190
+ if (character === opener)
191
+ depth += 1;
192
+ if (character === closer) {
193
+ depth -= 1;
194
+ if (depth === 0) {
195
+ try {
196
+ return JSON.parse(body.slice(start, index + 1));
197
+ }
198
+ catch {
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+ const ENTRY_KINDS = new Set(ENTRY_POINT_KINDS);
208
+ const SYMBOL_KIND = {
209
+ function: "function",
210
+ class: "class",
211
+ method: "method",
212
+ const: "const",
213
+ type: "type",
214
+ interface: "type",
215
+ enum: "type",
216
+ };
217
+ /**
218
+ * What the model picked, kept only when its file is on the shortlist and its
219
+ * name is declared or exported in that file, and only when both pass the data
220
+ * boundary. Anything else is dropped. The measurement kept any name in a
221
+ * listed file; a name the file does not declare would read as gone on the next
222
+ * push, so it is dropped here instead (2 of the 1,268 measured picks).
223
+ */
224
+ export function readPicks(raw, candidates, declared) {
225
+ const answer = (raw ?? {});
226
+ const byFile = new Map(candidates.map((entry) => [entry.file, entry]));
227
+ const dropped = [];
228
+ const keep = (file, name) => {
229
+ if (typeof file !== "string" || typeof name !== "string" || name === "")
230
+ return undefined;
231
+ const known = byFile.get(file);
232
+ if (known === undefined)
233
+ return undefined;
234
+ if (!isAnchorPath(known.file) || !isAnchorSymbol(name))
235
+ return undefined;
236
+ return declared(known.file)?.has(name) ? known : undefined;
237
+ };
238
+ const entryPoints = [];
239
+ for (const entry of Array.isArray(answer.entryPoints) ? answer.entryPoints : []) {
240
+ const row = (entry ?? {});
241
+ const known = keep(row.file, row.symbol);
242
+ if (known === undefined || entryPoints.length >= ANCHOR_LIMITS.entryPoints) {
243
+ dropped.push(`entry ${String(row.file)}#${String(row.symbol)}`);
244
+ continue;
245
+ }
246
+ const symbol = row.symbol;
247
+ if (entryPoints.some((kept) => kept.file === known.file && kept.symbol === symbol))
248
+ continue;
249
+ entryPoints.push({
250
+ file: known.file,
251
+ symbol,
252
+ kind: (typeof row.kind === "string" && ENTRY_KINDS.has(row.kind)
253
+ ? row.kind
254
+ : "function"),
255
+ });
256
+ }
257
+ const symbols = [];
258
+ for (const entry of Array.isArray(answer.symbols) ? answer.symbols : []) {
259
+ const row = (entry ?? {});
260
+ const known = keep(row.file, row.name);
261
+ if (known === undefined || symbols.length >= ANCHOR_LIMITS.symbols) {
262
+ dropped.push(`symbol ${String(row.file)}#${String(row.name)}`);
263
+ continue;
264
+ }
265
+ const name = row.name;
266
+ if (symbols.some((kept) => kept.file === known.file && kept.name === name))
267
+ continue;
268
+ const declaredKind = known.names.find((exported) => exported.name === name)?.kind;
269
+ symbols.push({
270
+ file: known.file,
271
+ name,
272
+ kind: SYMBOL_KIND[declaredKind ?? "function"] ?? "function",
273
+ });
274
+ }
275
+ return { entryPoints, symbols, dropped };
276
+ }
277
+ /** Linked tests, from the graph: test files that import an anchor file directly. */
278
+ export function linkedTestsFor(graph, files) {
279
+ const tests = new Set();
280
+ for (const file of files)
281
+ for (const importer of graph.importedBy[file] ?? [])
282
+ if (isTestPath(importer))
283
+ tests.add(importer);
284
+ return [...tests]
285
+ .filter((file) => isAnchorPath(file))
286
+ .slice(0, ANCHOR_LIMITS.linkedTests)
287
+ .map((file) => ({ file }));
288
+ }
289
+ /**
290
+ * Files one import step from the anchor files, either way: what they import
291
+ * and what imports them, tests left out (linked tests are listed apart). This
292
+ * is the judge's neighborhood: where to look next if the behavior reaches
293
+ * further than the anchors.
294
+ */
295
+ export function neighborhood(graph, files, limit = 30) {
296
+ const anchors = new Set(files);
297
+ const found = new Set();
298
+ for (const file of files) {
299
+ for (const next of graph.imports[file] ?? [])
300
+ if (!anchors.has(next))
301
+ found.add(next);
302
+ for (const next of graph.importedBy[file] ?? [])
303
+ if (!anchors.has(next))
304
+ found.add(next);
305
+ }
306
+ return [...found]
307
+ .filter((file) => !isTestPath(file))
308
+ .sort()
309
+ .slice(0, limit);
310
+ }
311
+ /**
312
+ * Anchors cut from a behavior map, with no model: its entry points, its core
313
+ * symbols, its resources and its linked tests, each kept only when it passes
314
+ * the data boundary, up to the contract's limits. A map's notes are not
315
+ * carried. Undefined when nothing anchorable is left.
316
+ */
317
+ export function seedFromMap(map, at) {
318
+ const entryPoints = map.entryPoints
319
+ .filter((entry) => isAnchorPath(entry.file) && isAnchorSymbol(entry.symbol))
320
+ .slice(0, ANCHOR_LIMITS.entryPoints)
321
+ .map((entry) => ({ file: entry.file, symbol: entry.symbol, kind: entry.kind }));
322
+ const symbols = map.symbols
323
+ .filter((symbol) => symbol.role === "core")
324
+ .filter((symbol) => isAnchorPath(symbol.file) && isAnchorSymbol(symbol.name))
325
+ .slice(0, ANCHOR_LIMITS.symbols)
326
+ .map((symbol) => ({ file: symbol.file, name: symbol.name, kind: symbol.kind }));
327
+ if (entryPoints.length + symbols.length === 0)
328
+ return undefined;
329
+ const anchors = {
330
+ schemaVersion: PROMISE_ANCHORS_SCHEMA_VERSION,
331
+ promiseId: map.promiseId,
332
+ semanticDigest: map.semanticDigest,
333
+ anchoredAt: {
334
+ sha: map.mappedAt.sha,
335
+ at: at.toISOString(),
336
+ source: BEHAVIOR_MAP_SOURCE,
337
+ client: null,
338
+ model: null,
339
+ },
340
+ entryPoints,
341
+ symbols,
342
+ resources: map.resources
343
+ .filter((resource) => isAnchorResourceName(resource.name))
344
+ .slice(0, ANCHOR_LIMITS.resources)
345
+ .map((resource) => ({ kind: resource.kind, name: resource.name, access: resource.access })),
346
+ linkedTests: map.linkedTests
347
+ .filter((test) => isAnchorPath(test.file))
348
+ .slice(0, ANCHOR_LIMITS.linkedTests)
349
+ .map((test) => ({ file: test.file })),
350
+ };
351
+ return checkPromiseAnchors(anchors).ok ? anchors : undefined;
352
+ }
353
+ /**
354
+ * Which anchors have gone at a commit, read off the graph with no model: an
355
+ * entry point or symbol whose file is not in the tree, or whose name its file
356
+ * no longer declares. A file the graph does not read (another language, from a
357
+ * map) is checked for being there only.
358
+ */
359
+ export function goneAnchors(anchors, repository) {
360
+ const gone = [];
361
+ const present = (file, name) => {
362
+ if (!repository.tree.has(file))
363
+ return false;
364
+ const names = repository.declaredNames(file);
365
+ return names === undefined || names.has(name);
366
+ };
367
+ const entryPoints = anchors.entryPoints.filter((entry) => {
368
+ const kept = present(entry.file, entry.symbol);
369
+ if (!kept)
370
+ gone.push(`${entry.file}#${entry.symbol}`);
371
+ return kept;
372
+ });
373
+ const symbols = anchors.symbols.filter((symbol) => {
374
+ const kept = present(symbol.file, symbol.name);
375
+ if (!kept)
376
+ gone.push(`${symbol.file}#${symbol.name}`);
377
+ return kept;
378
+ });
379
+ const linkedTests = anchors.linkedTests.filter((test) => repository.tree.has(test.file));
380
+ return { surviving: { ...anchors, entryPoints, symbols, linkedTests }, gone };
381
+ }
382
+ /** How many tokens a run spent: input, cache reads and writes included, and output. */
383
+ function spentTokens(agent) {
384
+ return (agent.inputTokens ?? 0) + (agent.outputTokens ?? 0);
385
+ }
386
+ /**
387
+ * One promise anchored by one headless call: no tools, in an empty folder,
388
+ * with an output-token cap and a time limit. Only picks from the shortlist are
389
+ * kept, linked tests come from the graph, and the result is checked against
390
+ * the data boundary before anyone sees it.
391
+ */
392
+ export async function anchorWithCall(input) {
393
+ const meaning = briefMeaning(input.meaning);
394
+ const candidates = shortlist(input.index, meaning);
395
+ const prompt = anchorBrief(meaning, candidates);
396
+ const cwd = mkdtempSync(join(input.scratch ?? tmpdir(), "balladeer-anchor-"));
397
+ const started = Date.now();
398
+ // Shorter than the cap only when the push check has less time than that left; running
399
+ // into that is the push's time running out, not the call running away.
400
+ const limit = Math.min(input.timeoutMs ?? ANCHOR_CALL_CAPS.wallMs, ANCHOR_CALL_CAPS.wallMs);
401
+ let agent;
402
+ try {
403
+ agent = await (input.runAgent ?? runHeadlessAgent)({
404
+ client: input.login.client,
405
+ login: input.login,
406
+ cwd,
407
+ prompt,
408
+ allowedTools: [],
409
+ tools: [],
410
+ sandbox: "read-only",
411
+ outsideRepository: true,
412
+ timeoutMs: limit,
413
+ maxOutputTokens: ANCHOR_CALL_CAPS.outputTokens,
414
+ env: input.environment,
415
+ jsonOutput: false,
416
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
417
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
418
+ });
419
+ }
420
+ finally {
421
+ rmSync(cwd, { recursive: true, force: true });
422
+ }
423
+ const tokens = spentTokens(agent);
424
+ if (agent.aborted)
425
+ return { status: "stopped", reason: "the push check's time ran out", tokens };
426
+ if (agent.reason === "output_capped" || agent.overOutputCap === true)
427
+ return {
428
+ status: "capped",
429
+ reason: `its call went past the ${ANCHOR_CALL_CAPS.outputTokens / 1000} thousand output-token cap and was stopped`,
430
+ tokens,
431
+ };
432
+ if (agent.reason === "timed_out")
433
+ return limit < ANCHOR_CALL_CAPS.wallMs
434
+ ? { status: "stopped", reason: "the push check's time ran out", tokens }
435
+ : {
436
+ status: "capped",
437
+ reason: `its call ran past ${ANCHOR_CALL_CAPS.wallMs / 1000} seconds and was stopped`,
438
+ tokens,
439
+ };
440
+ if (!agent.ok)
441
+ return {
442
+ status: "failed",
443
+ reason: `its call did not answer (${agent.reason ?? "no answer"})`,
444
+ tokens,
445
+ };
446
+ const raw = firstJsonValue(agent.text);
447
+ if (raw === undefined || raw === null || typeof raw !== "object")
448
+ return { status: "failed", reason: "its call's answer held no JSON", tokens };
449
+ const picks = readPicks(raw, candidates, (file) => input.repository.declaredNames(file));
450
+ if (picks.entryPoints.length + picks.symbols.length === 0)
451
+ return { status: "empty", tokens, dropped: picks.dropped };
452
+ const anchors = {
453
+ schemaVersion: PROMISE_ANCHORS_SCHEMA_VERSION,
454
+ promiseId: input.promiseId,
455
+ semanticDigest: input.semanticDigest,
456
+ anchoredAt: {
457
+ sha: input.repository.sha,
458
+ at: input.now().toISOString(),
459
+ source: ANCHOR_BRIEF_SOURCE,
460
+ client: input.login.client,
461
+ model: anchorModelName(agent.model),
462
+ },
463
+ entryPoints: picks.entryPoints,
464
+ symbols: picks.symbols,
465
+ resources: [],
466
+ linkedTests: linkedTestsFor(input.repository.graph, new Set([
467
+ ...picks.entryPoints.map((entry) => entry.file),
468
+ ...picks.symbols.map((symbol) => symbol.file),
469
+ ])),
470
+ };
471
+ const checked = checkPromiseAnchors(anchors);
472
+ if (!checked.ok)
473
+ return { status: "failed", reason: "its anchors failed the data boundary", tokens };
474
+ return {
475
+ status: "anchored",
476
+ anchors: checked.value,
477
+ tokens,
478
+ dropped: picks.dropped,
479
+ ms: Date.now() - started,
480
+ };
481
+ }
482
+ /** The model name as the contract carries it, or `unknown` when the run did not say. */
483
+ export function anchorModelName(model) {
484
+ const name = (model ?? "").trim();
485
+ return /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(name) ? name : "unknown";
486
+ }
487
+ /**
488
+ * Every agreed promise brought to current anchors as far as today's budget
489
+ * and the time allow: kept when current, cut from a current map with no model,
490
+ * else anchored by a call; anchors whose code has gone are anchored again, and
491
+ * judged on what is left of them meanwhile. A promise left unanchored says why.
492
+ */
493
+ export async function bringAnchorsCurrent(input) {
494
+ const run = await anchorEach(input);
495
+ const selectBy = new Map();
496
+ for (const promise of input.promises) {
497
+ const files = new Set();
498
+ if (promise.anchors !== null)
499
+ for (const file of anchorFiles(promise.anchors))
500
+ files.add(file);
501
+ const now = run.anchors.get(promise.promiseId);
502
+ if (now !== undefined)
503
+ for (const file of anchorFiles(now))
504
+ files.add(file);
505
+ if (files.size > 0)
506
+ selectBy.set(promise.promiseId, [...files].sort());
507
+ }
508
+ return { ...run, selectBy };
509
+ }
510
+ async function anchorEach(input) {
511
+ const anchors = new Map();
512
+ const outcomes = [];
513
+ let tokens = 0;
514
+ const needs = [];
515
+ for (const promise of input.promises) {
516
+ const forced = input.force?.has(promise.promiseId) === true;
517
+ const inScope = input.only === undefined || input.only.has(promise.promiseId) || forced;
518
+ const existing = promise.anchors;
519
+ if (existing !== null) {
520
+ const check = goneAnchors(existing, input.repository);
521
+ const usable = check.surviving.entryPoints.length + check.surviving.symbols.length > 0;
522
+ if (usable)
523
+ anchors.set(promise.promiseId, check.surviving);
524
+ if (!forced && (check.gone.length === 0 || !inScope)) {
525
+ if (check.gone.length === 0)
526
+ outcomes.push({ promiseId: promise.promiseId, title: promise.title, status: "current" });
527
+ else
528
+ outcomes.push({ promiseId: promise.promiseId, title: promise.title, status: "stale" });
529
+ continue;
530
+ }
531
+ needs.push({ promise, again: check.gone.length > 0 });
532
+ continue;
533
+ }
534
+ if (inScope)
535
+ needs.push({ promise, again: false });
536
+ }
537
+ const record = async (promise, fresh) => {
538
+ const sent = await input.client.record(fresh);
539
+ return sent.ok ? { recorded: true } : { recorded: false, recordReason: sent.reason };
540
+ };
541
+ // Seeds first: they cost nothing and need no login.
542
+ const calls = [];
543
+ for (const need of needs) {
544
+ const map = input.currentMaps.get(need.promise.promiseId);
545
+ const seeded = map === undefined ? undefined : seedFromMap(map, input.now());
546
+ if (seeded === undefined) {
547
+ calls.push(need);
548
+ continue;
549
+ }
550
+ anchors.set(need.promise.promiseId, goneAnchors(seeded, input.repository).surviving);
551
+ outcomes.push({
552
+ promiseId: need.promise.promiseId,
553
+ title: need.promise.title,
554
+ status: "seeded",
555
+ ...(need.again ? { again: true } : {}),
556
+ ...(await record(need.promise, seeded)),
557
+ });
558
+ }
559
+ if (calls.length === 0)
560
+ return { anchors, outcomes, tokens, budget: input.budget };
561
+ const settledKey = (promise) => `${input.repositoryKey}:${promise.promiseId}:${promise.semanticDigest}`;
562
+ const open = [];
563
+ for (const need of calls) {
564
+ const settled = input.budget.settledToday(settledKey(need.promise));
565
+ if (settled !== undefined && input.force?.has(need.promise.promiseId) !== true)
566
+ outcomes.push({
567
+ promiseId: need.promise.promiseId,
568
+ title: need.promise.title,
569
+ status: need.promise.anchors !== null && anchors.has(need.promise.promiseId)
570
+ ? "stale"
571
+ : "settled",
572
+ reason: settled === "empty"
573
+ ? "its call came back empty earlier today"
574
+ : "its call went past its cap earlier today",
575
+ });
576
+ else
577
+ open.push(need);
578
+ }
579
+ if (open.length === 0)
580
+ return { anchors, outcomes, tokens, budget: input.budget };
581
+ const login = await input.login();
582
+ if (login === undefined) {
583
+ for (const need of open)
584
+ outcomes.push({
585
+ promiseId: need.promise.promiseId,
586
+ title: need.promise.title,
587
+ status: anchors.has(need.promise.promiseId) ? "stale" : "no_login",
588
+ reason: "no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine",
589
+ });
590
+ return { anchors, outcomes, tokens, budget: input.budget };
591
+ }
592
+ const client = login.client;
593
+ const repositoryIndex = anchorIndex(input.repository);
594
+ const queue = [...open];
595
+ const worker = async () => {
596
+ for (;;) {
597
+ const need = queue.shift();
598
+ if (need === undefined)
599
+ return;
600
+ const { promise } = need;
601
+ const left = (status, reason, spent) => outcomes.push({
602
+ promiseId: promise.promiseId,
603
+ title: promise.title,
604
+ // A promise with anchors left over is still judged on them.
605
+ status: anchors.has(promise.promiseId) ? "stale" : status,
606
+ ...(reason === undefined ? {} : { reason }),
607
+ ...(spent === undefined ? {} : { tokens: spent }),
608
+ });
609
+ if (input.signal?.aborted ||
610
+ (input.deadline !== undefined && input.deadline - Date.now() < MIN_CALL_TIME_MS)) {
611
+ left("time", "the push check's time ran out before its turn");
612
+ continue;
613
+ }
614
+ const read = input.reader === undefined ? undefined : await input.reader.get(promise.promiseId);
615
+ if (read === undefined || !read.ok) {
616
+ left("unreadable", read?.ok === false ? read.reason : "its meaning could not be read here");
617
+ continue;
618
+ }
619
+ if (read.promise.semanticDigest !== promise.semanticDigest) {
620
+ left("unreadable", "its meaning changed while this push was being checked");
621
+ continue;
622
+ }
623
+ if (!input.budget.take()) {
624
+ left("budget", budgetSpent(input.budget.limit));
625
+ continue;
626
+ }
627
+ const remaining = input.deadline === undefined
628
+ ? ANCHOR_CALL_CAPS.wallMs
629
+ : Math.max(1000, input.deadline - Date.now());
630
+ const call = await anchorWithCall({
631
+ promiseId: promise.promiseId,
632
+ semanticDigest: promise.semanticDigest,
633
+ meaning: read.promise.meaning,
634
+ repository: input.repository,
635
+ index: repositoryIndex,
636
+ login,
637
+ environment: input.environment,
638
+ now: input.now,
639
+ timeoutMs: Math.min(ANCHOR_CALL_CAPS.wallMs, remaining),
640
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
641
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
642
+ ...(input.runAgent === undefined ? {} : { runAgent: input.runAgent }),
643
+ });
644
+ tokens += call.tokens;
645
+ if (call.status === "anchored") {
646
+ anchors.set(promise.promiseId, call.anchors);
647
+ outcomes.push({
648
+ promiseId: promise.promiseId,
649
+ title: promise.title,
650
+ status: "anchored",
651
+ tokens: call.tokens,
652
+ ...(need.again ? { again: true } : {}),
653
+ ...(await record(promise, call.anchors)),
654
+ });
655
+ continue;
656
+ }
657
+ if (call.status === "empty") {
658
+ input.budget.settle(settledKey(promise), "empty");
659
+ left("empty", "nothing on its shortlist realizes it", call.tokens);
660
+ continue;
661
+ }
662
+ if (call.status === "capped")
663
+ input.budget.settle(settledKey(promise), "capped");
664
+ left(call.status === "stopped" ? "time" : call.status, call.reason, call.tokens);
665
+ }
666
+ };
667
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(input.concurrency ?? 3, open.length)) }, worker));
668
+ return {
669
+ anchors,
670
+ outcomes,
671
+ tokens,
672
+ client,
673
+ budget: input.budget,
674
+ };
675
+ }
676
+ function plural(count, one, many) {
677
+ return `${count} ${count === 1 ? one : many}`;
678
+ }
679
+ function budgetSpent(limit) {
680
+ return `this laptop's ${plural(limit, "anchoring", "anchorings")} for today ${limit === 1 ? "is" : "are"} used up`;
681
+ }
682
+ function aboutTokens(tokens) {
683
+ if (tokens < 1000)
684
+ return `about ${tokens} tokens`;
685
+ return `about ${Math.round(tokens / 1000)} thousand tokens`;
686
+ }
687
+ function subscription(client) {
688
+ return client === "codex" ? "your Codex subscription" : "your Claude subscription";
689
+ }
690
+ function named(outcomes, max = 3) {
691
+ const shown = outcomes.slice(0, max).map((outcome) => `${outcome.title} (${outcome.promiseId})`);
692
+ const more = outcomes.length > max ? ` and ${outcomes.length - max} more` : "";
693
+ return `${shown.join("; ")}${more}`;
694
+ }
695
+ /**
696
+ * What anchoring did, in at most four lines a person reads in one go: what
697
+ * was anchored and what it cost with what is left of today's budget; what was
698
+ * left unanchored, so not checked, and why; what is checked on part of its
699
+ * anchors; and anchors not shared with the team. Nothing when anchoring had
700
+ * nothing to do.
701
+ *
702
+ * From the push check (`push`), a promise left unanchored that the push
703
+ * selected anyway (by an anchor file it moved or deleted) is not listed as
704
+ * unchecked, because it was judged; from `balladeer anchor` there is no push,
705
+ * and the lines say what the push check can do instead.
706
+ */
707
+ export function anchoringLines(run, context = { push: true }) {
708
+ const lines = [];
709
+ const by = (status) => run.outcomes.filter((outcome) => outcome.status === status &&
710
+ (status === "anchored" ||
711
+ status === "seeded" ||
712
+ context.judged?.has(outcome.promiseId) !== true));
713
+ const called = by("anchored");
714
+ const seeded = by("seeded");
715
+ const fresh = [...called, ...seeded];
716
+ const left = run.budget.remaining();
717
+ const remaining = `${plural(left, "anchoring", "anchorings")} left today`;
718
+ if (fresh.length > 0) {
719
+ const again = fresh.filter((outcome) => outcome.again === true).length;
720
+ const againText = again === 0
721
+ ? ""
722
+ : ` (${again === fresh.length && again === 1 ? "again, because" : `${again} of them again, because`} code it was anchored to is gone)`;
723
+ const head = `Balladeer anchored ${plural(fresh.length, "promise", "promises")} to this repository's code${againText}`;
724
+ if (called.length === 0)
725
+ lines.push(`${head} from ${seeded.length === 1 ? "its behavior map" : "their behavior maps"}, with no model call (${remaining}).`);
726
+ else if (seeded.length === 0)
727
+ lines.push(`${head} on ${subscription(run.client)} (${aboutTokens(run.tokens)}; ${remaining}).`);
728
+ else
729
+ lines.push(`${head}: ${seeded.length} from ${seeded.length === 1 ? "its behavior map" : "behavior maps"}, and ${called.length} on ${subscription(run.client)} (${aboutTokens(run.tokens)}; ${remaining}).`);
730
+ }
731
+ const reasons = [
732
+ ["budget", `${budgetSpent(run.budget.limit)}; anchoring starts again on a push tomorrow`],
733
+ ["capped", "the call went past its cap and was stopped"],
734
+ ["failed", "the call did not give usable anchors"],
735
+ ["empty", "its call found nothing in this repository's code that realizes it"],
736
+ ["settled", "its call came back empty or capped earlier today"],
737
+ [
738
+ "no_login",
739
+ "no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this laptop",
740
+ ],
741
+ ["unreadable", "its meaning could not be read"],
742
+ ["time", "the push check ran out of time"],
743
+ ];
744
+ const parts = [];
745
+ let count = 0;
746
+ for (const [status, why] of reasons) {
747
+ const these = by(status);
748
+ if (these.length === 0)
749
+ continue;
750
+ count += these.length;
751
+ parts.push(`${named(these)}: ${why}`);
752
+ }
753
+ const one = count === 1;
754
+ if (count > 0)
755
+ lines.push(`Balladeer could not anchor ${plural(count, "promise", "promises")}, so ${context.push
756
+ ? `this push did not check ${one ? "it" : "them"}`
757
+ : `the push check cannot select ${one ? "it" : "them"} until ${one ? "it is" : "they are"} anchored`}. ${parts.join(". ")}.`);
758
+ const stale = by("stale");
759
+ const single = stale.length === 1;
760
+ if (stale.length > 0)
761
+ lines.push(`Code that ${plural(stale.length, "promise was", "promises were")} anchored to is gone, and ${single ? "it was" : "they were"} not anchored again yet, so ${single ? "it is" : "they are"} checked on what is left of ${single ? "its" : "their"} anchors: ${named(stale)}.`);
762
+ const unshared = fresh.filter((outcome) => outcome.recorded === false);
763
+ if (unshared.length > 0)
764
+ lines.push(`The anchors for ${named(unshared)} ${context.push ? "were used on this push but " : "were "}not shared with your team: ${unshared[0]?.recordReason ?? "Balladeer did not keep them"}.`);
765
+ return lines;
766
+ }