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,601 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { BEHAVIOR_MAP_DIRECTORY, BEHAVIOR_MAP_SCHEMA_VERSION, ENTRY_POINT_KINDS, PROMISE_ID, RESOURCE_ACCESS, RESOURCE_KINDS, SYMBOL_KINDS, SYMBOL_ROLES, parseBehaviorMap, serializeBehaviorMap, } from "../behavior-map-schema.js";
4
+ import { repositoryRoot } from "../git.js";
5
+ import { runHeadlessAgent, } from "../headless-agent.js";
6
+ import { mapPrompt } from "../map-brief.js";
7
+ import { connectionReader, meaningExcerpt, readPromiseFile, } from "../promise-meaning.js";
8
+ import { addScratchWorktree, resolveCommit } from "../scratch-worktree.js";
9
+ import { normalizeControlPlane, StoreError } from "../store.js";
10
+ import { CLI_INVOCATION, DEFAULT_CONTROL_PLANE } from "../wire.js";
11
+ /** Tools a map builder or a judge may use, and the ones refused whatever else is allowed. */
12
+ export const AGENT_TOOLS = ["Read", "Grep", "Glob", "Bash"];
13
+ export const REFUSED_TOOL_RULES = ["Bash(git push:*)", "Bash(gh:*)"];
14
+ const DEFAULT_MAP_TIMEOUT_MS = 15 * 60_000;
15
+ export const MAP_USAGE = ` ${CLI_INVOCATION} map <promise id> | --all | --check | --stale
16
+ [--from-file <promise.json>] [--repo <root>] [--out <dir>]
17
+ [--client auto|claude|codex] [--timeout <seconds>] [--json]
18
+ Build a behavior map for a promise with your own coding agent: where its
19
+ behavior starts, the code on its path, what it depends on, the tests that
20
+ exercise it and where each failing case would show. The agent works in a
21
+ throwaway read-only checkout, on the Claude Code or Codex login already on
22
+ this machine, and the map is written to .continuity/behavior-maps/<id>.json
23
+ for your team to commit. It never leaves this machine. --all maps every
24
+ agreed promise; --check reports maps whose code or meaning has moved
25
+ (exit 1 when any has) and --stale prints just their ids, both without a
26
+ model. --from-file reads the promise from a file instead of Balladeer.
27
+ `;
28
+ export function parseMapArguments(argv) {
29
+ const args = [...argv];
30
+ const positional = [];
31
+ let all = false;
32
+ let check = false;
33
+ let stale = false;
34
+ let json = false;
35
+ let fromFile;
36
+ let repo;
37
+ let out;
38
+ let repository;
39
+ let controlPlane;
40
+ let client = "auto";
41
+ let timeoutMs = DEFAULT_MAP_TIMEOUT_MS;
42
+ const value = (flag, inline) => {
43
+ const next = inline ?? args.shift();
44
+ if (next === undefined || next === "")
45
+ throw new StoreError("usage", `${flag} needs a value.`);
46
+ return next;
47
+ };
48
+ while (args.length > 0) {
49
+ const arg = args.shift();
50
+ if (!arg.startsWith("--")) {
51
+ positional.push(arg);
52
+ continue;
53
+ }
54
+ const [name, ...rest] = arg.split("=");
55
+ const inline = rest.length > 0 ? rest.join("=") : undefined;
56
+ if (name === "--all")
57
+ all = true;
58
+ else if (name === "--check")
59
+ check = true;
60
+ else if (name === "--stale")
61
+ stale = true;
62
+ else if (name === "--json")
63
+ json = true;
64
+ else if (name === "--from-file")
65
+ fromFile = value(name, inline);
66
+ else if (name === "--repo")
67
+ repo = value(name, inline);
68
+ else if (name === "--out")
69
+ out = value(name, inline);
70
+ else if (name === "--repository")
71
+ repository = value(name, inline);
72
+ else if (name === "--control-plane")
73
+ controlPlane = value(name, inline);
74
+ else if (name === "--client") {
75
+ const requested = value(name, inline);
76
+ if (requested !== "auto" && requested !== "claude" && requested !== "codex")
77
+ throw new StoreError("usage", "--client needs auto, claude or codex.");
78
+ client = requested;
79
+ }
80
+ else if (name === "--timeout") {
81
+ const seconds = Number(value(name, inline));
82
+ if (!Number.isFinite(seconds) || seconds <= 0)
83
+ throw new StoreError("usage", "--timeout needs a number of seconds.");
84
+ timeoutMs = Math.round(seconds * 1000);
85
+ }
86
+ else
87
+ throw new StoreError("usage", `Unknown option ${arg} for map.`);
88
+ }
89
+ if (positional.length > 1)
90
+ throw new StoreError("usage", "map takes at most one promise id.");
91
+ const modes = [positional.length === 1, all, check, stale, fromFile !== undefined].filter(Boolean);
92
+ if (modes.length === 0)
93
+ throw new StoreError("usage", "map needs a promise id, --all, --check, --stale or --from-file.");
94
+ if (modes.length > 1)
95
+ throw new StoreError("usage", "map takes one of: a promise id, --all, --check, --stale, --from-file.");
96
+ const promiseId = positional[0];
97
+ if (promiseId !== undefined && !PROMISE_ID.test(promiseId))
98
+ throw new StoreError("usage", `"${promiseId}" is not a promise id.`);
99
+ const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
100
+ return {
101
+ ...(promiseId === undefined ? {} : { promiseId }),
102
+ all,
103
+ check,
104
+ stale,
105
+ ...(fromFile === undefined ? {} : { fromFile }),
106
+ ...(repo === undefined ? {} : { repo }),
107
+ ...(out === undefined ? {} : { out }),
108
+ client,
109
+ timeoutMs,
110
+ json,
111
+ ...(repository === undefined ? {} : { repository }),
112
+ controlPlane: normalizeControlPlane(chosen),
113
+ };
114
+ }
115
+ /**
116
+ * One promise's behavior map, built by the person's own agent from the code at
117
+ * HEAD, in a disposable checkout, and checked against the contract before it is
118
+ * returned. Nothing is written here; the caller decides where the map goes.
119
+ */
120
+ export async function buildBehaviorMap(input) {
121
+ const sha = await resolveCommit(input.root, "HEAD");
122
+ if (sha === undefined)
123
+ return { ok: false, reason: "this repository has no commit to map" };
124
+ const tree = await addScratchWorktree(input.root, sha, `map-${input.promise.promiseId}`, input.scratch);
125
+ if (!tree.ok)
126
+ return { ok: false, reason: tree.reason };
127
+ try {
128
+ const agent = await (input.runAgent ?? runHeadlessAgent)({
129
+ client: input.client,
130
+ cwd: tree.worktree.path,
131
+ prompt: mapPrompt(input.promise),
132
+ allowedTools: AGENT_TOOLS,
133
+ disallowedTools: REFUSED_TOOL_RULES,
134
+ sandbox: "read-only",
135
+ timeoutMs: input.timeoutMs,
136
+ env: input.env,
137
+ jsonOutput: true,
138
+ ...(input.prefer === undefined ? {} : { prefer: input.prefer }),
139
+ ...(input.login === undefined ? {} : { login: input.login }),
140
+ ...(input.scratch === undefined ? {} : { scratch: input.scratch }),
141
+ });
142
+ if (!agent.ok || agent.json === undefined)
143
+ return { ok: false, reason: agentFailure(agent), agent };
144
+ const { map, dropped, coerced } = mapFromAgent(agent.json, {
145
+ promise: input.promise,
146
+ sha,
147
+ client: agent.client === "codex" ? "codex" : "claude",
148
+ model: agent.model ?? "",
149
+ at: (input.now?.() ?? new Date()).toISOString(),
150
+ tree: tree.worktree.path,
151
+ });
152
+ const parsed = parseBehaviorMap(map);
153
+ if (!parsed.ok)
154
+ return {
155
+ ok: false,
156
+ reason: "the agent's map does not match the behavior map contract",
157
+ agent,
158
+ errors: parsed.errors,
159
+ };
160
+ return { ok: true, map: parsed.value, agent, dropped, coerced };
161
+ }
162
+ finally {
163
+ await tree.worktree.remove();
164
+ }
165
+ }
166
+ export function agentFailure(agent) {
167
+ switch (agent.reason) {
168
+ case "no_verified_login":
169
+ return "no Claude Code signed in through claude.ai, and no Codex signed in with ChatGPT, is on this machine";
170
+ case "spawn_failed":
171
+ return "the coding agent could not be started";
172
+ case "timed_out":
173
+ return `the agent was still working after ${Math.round(agent.durationMs / 1000)} seconds and was stopped`;
174
+ case "aborted":
175
+ return "the time allowed ran out and the agent was stopped";
176
+ case "output_bounded":
177
+ return "the agent printed more than this command keeps, and was stopped";
178
+ case "exit_nonzero":
179
+ return `the agent exited with code ${agent.exitCode ?? "none"}${agent.stderrTail ? `: ${agent.stderrTail.split("\n").pop()}` : ""}`;
180
+ case "no_answer":
181
+ return "the agent finished without an answer";
182
+ case "no_json":
183
+ return "the agent's answer held no JSON object";
184
+ default:
185
+ return "the agent did not answer";
186
+ }
187
+ }
188
+ /** A path the agent wrote, as a repository-relative forward-slash path, or nothing. */
189
+ function repositoryRelative(value, tree) {
190
+ if (typeof value !== "string" || value.trim() === "")
191
+ return undefined;
192
+ let path = value.trim().replace(/\\/g, "/");
193
+ if (isAbsolute(path)) {
194
+ const inside = relative(tree, path);
195
+ if (inside.startsWith("..") || isAbsolute(inside))
196
+ return undefined;
197
+ path = inside.split(sep).join("/");
198
+ }
199
+ path = path.replace(/^\.\//, "");
200
+ if (path === "" || path.split("/").includes(".."))
201
+ return undefined;
202
+ return path;
203
+ }
204
+ const asText = (value, fallback = "") => typeof value === "string" ? value : fallback;
205
+ const asList = (value) => (Array.isArray(value) ? value : []);
206
+ const asRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
207
+ ? value
208
+ : {};
209
+ /**
210
+ * The agent's answer made into a map: the runner's own fields filled in, paths
211
+ * made repository-relative, and every entry whose file does not exist at the
212
+ * mapped commit dropped and named, because a map pointing at nothing reads as
213
+ * stale forever. The meaning excerpt is taken from the promise itself rather
214
+ * than from the agent's copy, so a transcription slip cannot change it.
215
+ */
216
+ export function mapFromAgent(json, context) {
217
+ const dropped = [];
218
+ const coerced = [];
219
+ const exists = (path, what) => {
220
+ if (path !== undefined && existsSync(join(context.tree, path)))
221
+ return true;
222
+ dropped.push(`${what} ${path ?? "(no path)"}`);
223
+ return false;
224
+ };
225
+ // The contract's closed lists are the contract's, not the agent's. An agent
226
+ // that calls a maintenance sweep a "sweep" or a resource "database" has
227
+ // still found the right thing; refusing the whole map over one word threw
228
+ // away a minute of a person's subscription on the first live run. So the
229
+ // word is read as the nearest allowed one and the reading is reported, never
230
+ // silent, so a person can see what the agent actually said.
231
+ const pick = (value, allowed, fallback, what) => {
232
+ if (typeof value === "string" && allowed.includes(value))
233
+ return value;
234
+ coerced.push(`${what} "${typeof value === "string" ? value : String(value)}" read as ${fallback}`);
235
+ return fallback;
236
+ };
237
+ const entryPoints = asList(json.entryPoints).flatMap((raw) => {
238
+ const row = asRecord(raw);
239
+ const file = repositoryRelative(row.file, context.tree);
240
+ if (!exists(file, "entry point in"))
241
+ return [];
242
+ return [
243
+ {
244
+ file,
245
+ symbol: asText(row.symbol),
246
+ kind: pick(row.kind, ENTRY_POINT_KINDS, "function", `entry point kind in ${file}`),
247
+ note: asText(row.note),
248
+ },
249
+ ];
250
+ });
251
+ const symbols = asList(json.symbols).flatMap((raw) => {
252
+ const row = asRecord(raw);
253
+ const file = repositoryRelative(row.file, context.tree);
254
+ if (!exists(file, "symbol in"))
255
+ return [];
256
+ return [
257
+ {
258
+ file,
259
+ name: asText(row.name),
260
+ kind: pick(row.kind, SYMBOL_KINDS, "function", `symbol kind of ${asText(row.name)}`),
261
+ role: pick(row.role, SYMBOL_ROLES, "supporting", `symbol role of ${asText(row.name)}`),
262
+ },
263
+ ];
264
+ });
265
+ const linkedTests = asList(json.linkedTests).flatMap((raw) => {
266
+ const row = asRecord(raw);
267
+ const file = repositoryRelative(row.file, context.tree);
268
+ if (!exists(file, "linked test"))
269
+ return [];
270
+ return [{ file, name: asText(row.name), cases: asList(row.cases) }];
271
+ });
272
+ const resources = asList(json.resources).map((raw) => {
273
+ const row = asRecord(raw);
274
+ return {
275
+ kind: pick(row.kind, RESOURCE_KINDS, "config", `resource kind of ${asText(row.name)}`),
276
+ name: asText(row.name),
277
+ access: pick(row.access, RESOURCE_ACCESS, "both", `resource access of ${asText(row.name)}`),
278
+ };
279
+ });
280
+ const sinks = asList(json.sinks).flatMap((raw) => {
281
+ const row = asRecord(raw);
282
+ // A sink names the failing case it belongs to, and a case id the meaning
283
+ // does not have points at nothing. Left out and named, like a missing file.
284
+ if (typeof row.caseId !== "string" || !/^failing-[1-9][0-9]*$/.test(row.caseId)) {
285
+ dropped.push(`sink with case id ${typeof row.caseId === "string" ? row.caseId : "(none)"}`);
286
+ return [];
287
+ }
288
+ return [
289
+ {
290
+ caseId: row.caseId,
291
+ effect: asText(row.effect),
292
+ resources: asList(row.resources),
293
+ symbols: asList(row.symbols).flatMap((reference) => {
294
+ if (typeof reference !== "string")
295
+ return [];
296
+ const hash = reference.indexOf("#");
297
+ const file = repositoryRelative(hash > 0 ? reference.slice(0, hash) : undefined, context.tree);
298
+ if (!exists(file, "sink symbol in"))
299
+ return [];
300
+ return [`${file}#${reference.slice(hash + 1)}`];
301
+ }),
302
+ },
303
+ ];
304
+ });
305
+ const rawConfidence = typeof json.confidence === "number" ? json.confidence : 0;
306
+ return {
307
+ map: {
308
+ schemaVersion: BEHAVIOR_MAP_SCHEMA_VERSION,
309
+ promiseId: context.promise.promiseId,
310
+ semanticDigest: context.promise.semanticDigest,
311
+ mappedAt: { sha: context.sha, at: context.at, client: context.client, model: context.model },
312
+ entryPoints,
313
+ symbols,
314
+ resources,
315
+ linkedTests,
316
+ sinks,
317
+ confidence: Math.min(1, Math.max(0, rawConfidence)),
318
+ notes: asText(json.notes).slice(0, 2000),
319
+ meaning: meaningExcerpt(context.promise),
320
+ },
321
+ dropped,
322
+ coerced,
323
+ };
324
+ }
325
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
326
+ /** The name a file must still contain for a mapped symbol to count as present. */
327
+ function searchName(name) {
328
+ const parts = name.split(/[.#:]+|\(\)/).filter((part) => part.trim() !== "");
329
+ return (parts[parts.length - 1] ?? name).trim();
330
+ }
331
+ function fileHas(root, file, name) {
332
+ let content;
333
+ try {
334
+ const path = join(root, file);
335
+ if (!statSync(path).isFile())
336
+ return false;
337
+ content = readFileSync(path, "utf8");
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ const needle = searchName(name);
343
+ if (needle === "")
344
+ return true;
345
+ if (IDENTIFIER.test(needle)) {
346
+ const escaped = needle.replace(/\$/g, "\\$");
347
+ return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}($|[^A-Za-z0-9_$])`, "m").test(content);
348
+ }
349
+ return content.includes(needle);
350
+ }
351
+ /**
352
+ * Whether a map still points at real code, answered without a model.
353
+ *
354
+ * Stale when a mapped symbol is no longer in its file (a plain search for its
355
+ * name, the way a person would grep for it), when a mapped file is gone, or,
356
+ * when the current digest is known, when the promise's meaning has moved on
357
+ * since the map was built.
358
+ */
359
+ export function mapStaleness(root, map, currentDigest) {
360
+ const reasons = [];
361
+ for (const entry of map.entryPoints)
362
+ if (!fileHas(root, entry.file, entry.symbol))
363
+ reasons.push(`entry point ${entry.symbol} is no longer in ${entry.file}`);
364
+ for (const symbol of map.symbols) {
365
+ const present = symbol.kind === "module"
366
+ ? existsSync(join(root, symbol.file))
367
+ : fileHas(root, symbol.file, symbol.name);
368
+ if (!present)
369
+ reasons.push(`${symbol.name} is no longer in ${symbol.file}`);
370
+ }
371
+ for (const test of map.linkedTests)
372
+ if (!existsSync(join(root, test.file)))
373
+ reasons.push(`linked test ${test.file} is gone`);
374
+ for (const sink of map.sinks)
375
+ for (const reference of sink.symbols) {
376
+ const hash = reference.indexOf("#");
377
+ const file = reference.slice(0, hash);
378
+ const name = reference.slice(hash + 1);
379
+ if (!fileHas(root, file, name))
380
+ reasons.push(`${name} is no longer in ${file}`);
381
+ }
382
+ if (currentDigest !== undefined && currentDigest !== map.semanticDigest)
383
+ reasons.push("the promise's agreed meaning has changed since this map was built");
384
+ return {
385
+ stale: reasons.length > 0,
386
+ reasons: [...new Set(reasons)],
387
+ digestChecked: currentDigest !== undefined,
388
+ };
389
+ }
390
+ /** Every map in a directory, read and checked; an unreadable one is listed with why. */
391
+ export function readBehaviorMaps(directory) {
392
+ if (!existsSync(directory))
393
+ return [];
394
+ const out = [];
395
+ for (const name of readdirSync(directory).sort()) {
396
+ if (!name.endsWith(".json"))
397
+ continue;
398
+ const path = join(directory, name);
399
+ const promiseId = name.slice(0, -".json".length);
400
+ try {
401
+ const parsed = parseBehaviorMap(JSON.parse(readFileSync(path, "utf8")));
402
+ out.push(parsed.ok
403
+ ? { promiseId, path, map: parsed.value }
404
+ : { promiseId, path, errors: parsed.errors });
405
+ }
406
+ catch {
407
+ out.push({ promiseId, path, errors: ["the file is not JSON"] });
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+ export function readBehaviorMap(root, promiseId) {
413
+ const path = join(root, BEHAVIOR_MAP_DIRECTORY, `${promiseId}.json`);
414
+ if (!existsSync(path))
415
+ return undefined;
416
+ try {
417
+ const parsed = parseBehaviorMap(JSON.parse(readFileSync(path, "utf8")));
418
+ return parsed.ok ? parsed.value : undefined;
419
+ }
420
+ catch {
421
+ return undefined;
422
+ }
423
+ }
424
+ /** `balladeer map`, from arguments already parsed. */
425
+ export async function runMap(options) {
426
+ const { args } = options;
427
+ const emit = (step) => {
428
+ if (args.json)
429
+ options.write(`${JSON.stringify(step)}\n`);
430
+ };
431
+ const say = (line) => {
432
+ if (!args.json)
433
+ options.write(`${line}\n`);
434
+ };
435
+ const fail = (reason, message, exitCode) => {
436
+ if (args.json)
437
+ emit({ step: "error", reason, message, changed: false, exitCode });
438
+ else
439
+ options.write(`${message}\n`);
440
+ return exitCode;
441
+ };
442
+ const root = args.repo !== undefined ? resolve(options.cwd, args.repo) : await repositoryRoot(options.cwd);
443
+ if (root === undefined || !existsSync(root))
444
+ return fail("not_a_repository", "This is not a git repository, so there is nothing to map.", 4);
445
+ const directory = args.out !== undefined ? resolve(options.cwd, args.out) : join(root, BEHAVIOR_MAP_DIRECTORY);
446
+ let reader = options.deps?.reader ?? undefined;
447
+ let readerProblem;
448
+ if (options.deps?.reader === undefined && args.fromFile === undefined) {
449
+ const connection = connectionReader({
450
+ environment: options.environment,
451
+ controlPlane: args.controlPlane,
452
+ cwd: root,
453
+ ...(args.repository === undefined ? {} : { repository: args.repository }),
454
+ });
455
+ if (connection.ok)
456
+ reader = connection.reader;
457
+ else
458
+ readerProblem = connection.reason;
459
+ }
460
+ if (args.check || args.stale) {
461
+ const stored = readBehaviorMaps(directory);
462
+ if (stored.length === 0) {
463
+ say(`There are no behavior maps in ${relative(options.cwd, directory) || "."} yet.`);
464
+ emit({ step: "behavior_maps", checked: 0, stale: [], digestChecked: false });
465
+ return 0;
466
+ }
467
+ const stale = [];
468
+ let digestChecked = reader !== undefined;
469
+ for (const entry of stored) {
470
+ if (entry.map === undefined) {
471
+ stale.push(entry.promiseId);
472
+ if (args.check)
473
+ say(`unreadable ${entry.promiseId}: ${(entry.errors ?? []).slice(0, 3).join("; ")}`);
474
+ continue;
475
+ }
476
+ let current;
477
+ if (reader !== undefined) {
478
+ const read = await reader.get(entry.promiseId);
479
+ if (read.ok)
480
+ current = read.promise.semanticDigest;
481
+ else
482
+ digestChecked = false;
483
+ }
484
+ const verdict = mapStaleness(root, entry.map, current);
485
+ const title = entry.map.meaning?.title ?? "";
486
+ if (verdict.stale) {
487
+ stale.push(entry.promiseId);
488
+ if (args.check)
489
+ say(`stale ${entry.promiseId}${title ? ` (${title})` : ""}: ${verdict.reasons.slice(0, 5).join("; ")}`);
490
+ }
491
+ else if (args.check)
492
+ say(`fresh ${entry.promiseId}${title ? ` (${title})` : ""}`);
493
+ }
494
+ if (args.stale && !args.json)
495
+ for (const id of stale)
496
+ options.write(`${id}\n`);
497
+ if (args.check && !digestChecked)
498
+ say(`Meaning changes were not checked${readerProblem ? ` (${readerProblem})` : ""}; only the code each map points at was.`);
499
+ emit({ step: "behavior_maps", checked: stored.length, stale, digestChecked });
500
+ return args.check && stale.length > 0 ? 1 : 0;
501
+ }
502
+ const promises = [];
503
+ if (args.fromFile !== undefined) {
504
+ const read = readPromiseFile(resolve(options.cwd, args.fromFile));
505
+ if (!read.ok)
506
+ return fail("promise_unreadable", read.reason, 4);
507
+ promises.push(read.promise);
508
+ }
509
+ else {
510
+ if (reader === undefined)
511
+ return fail("no_agent_connection", `Reading the promise needs this repository's Balladeer connection: ${readerProblem ?? "none was found"}. Pass --from-file with the promise instead.`, 4);
512
+ const ids = [];
513
+ if (args.all) {
514
+ const listed = await reader.list();
515
+ if (!listed.ok)
516
+ return fail("promise_list_failed", `Balladeer did not list the promises: ${listed.reason}`, 5);
517
+ ids.push(...listed.ids);
518
+ }
519
+ else if (args.promiseId !== undefined)
520
+ ids.push(args.promiseId);
521
+ for (const id of ids) {
522
+ const read = await reader.get(id);
523
+ if (!read.ok) {
524
+ say(`${id} was not mapped: ${read.reason}`);
525
+ emit({ step: "behavior_map", promiseId: id, status: "failed", reason: read.reason });
526
+ continue;
527
+ }
528
+ promises.push(read.promise);
529
+ }
530
+ }
531
+ let failures = 0;
532
+ for (const promise of promises) {
533
+ const built = await buildBehaviorMap({
534
+ root,
535
+ promise,
536
+ client: args.client,
537
+ env: options.environment,
538
+ timeoutMs: args.timeoutMs,
539
+ ...(options.deps?.login === undefined ? {} : { login: options.deps.login }),
540
+ ...(options.deps?.now === undefined ? {} : { now: options.deps.now }),
541
+ ...(options.deps?.runAgent === undefined ? {} : { runAgent: options.deps.runAgent }),
542
+ });
543
+ if (!built.ok) {
544
+ failures += 1;
545
+ say(`${promise.promiseId} was not mapped: ${built.reason}.`);
546
+ for (const error of (built.errors ?? []).slice(0, 8))
547
+ say(` ${error}`);
548
+ emit({
549
+ step: "behavior_map",
550
+ promiseId: promise.promiseId,
551
+ status: "failed",
552
+ reason: built.reason,
553
+ ...(built.errors === undefined ? {} : { errors: built.errors.slice(0, 20) }),
554
+ });
555
+ if (built.agent?.reason === "no_verified_login")
556
+ break;
557
+ continue;
558
+ }
559
+ mkdirSync(directory, { recursive: true });
560
+ const path = join(directory, `${promise.promiseId}.json`);
561
+ writeFileSync(path, serializeBehaviorMap(built.map), "utf8");
562
+ const shown = relative(options.cwd, path) || path;
563
+ const minutes = (built.agent.durationMs / 60_000).toFixed(1);
564
+ say(`Mapped ${promise.promiseId} (${promise.title}): ${built.map.entryPoints.length} entry points, ` +
565
+ `${built.map.symbols.length} symbols, ${built.map.linkedTests.length} linked tests, ` +
566
+ `confidence ${built.map.confidence}, in ${shown}. ` +
567
+ `${built.agent.client}${built.agent.model ? ` ${built.agent.model}` : ""}, ${minutes} min.`);
568
+ if (built.dropped.length > 0)
569
+ say(` Left out because the file does not exist: ${built.dropped.slice(0, 8).join("; ")}`);
570
+ if (built.coerced.length > 0)
571
+ say(` Read as the nearest allowed kind: ${built.coerced.slice(0, 8).join("; ")}`);
572
+ emit({
573
+ step: "behavior_map",
574
+ promiseId: promise.promiseId,
575
+ status: "written",
576
+ path,
577
+ client: built.agent.client === "codex" ? "codex" : "claude",
578
+ ...(built.agent.model === undefined ? {} : { model: built.agent.model }),
579
+ durationMs: built.agent.durationMs,
580
+ ...(built.agent.inputTokens === undefined ? {} : { inputTokens: built.agent.inputTokens }),
581
+ ...(built.agent.outputTokens === undefined ? {} : { outputTokens: built.agent.outputTokens }),
582
+ dropped: built.dropped.length,
583
+ coerced: built.coerced.length,
584
+ });
585
+ }
586
+ if (promises.length > 0 && failures === 0)
587
+ say("The map stays in this repository. Balladeer is never sent it.");
588
+ return failures > 0 ? 1 : 0;
589
+ }
590
+ /** `balladeer map ...` from raw arguments, as `cli.ts` hands them over. */
591
+ export async function mapCommand(argv, io) {
592
+ let args;
593
+ try {
594
+ args = parseMapArguments(argv);
595
+ }
596
+ catch (error) {
597
+ io.error(`${error instanceof Error ? error.message : String(error)}\n\n${MAP_USAGE}`);
598
+ return 4;
599
+ }
600
+ return runMap({ args, cwd: io.cwd, environment: io.environment, write: io.write });
601
+ }