balladeer 0.0.4 → 1.0.0

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 (60) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +154 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +34 -0
  6. package/dist/cli.js +392 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.js +114 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +122 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +395 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +197 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/propose.d.ts +59 -0
  22. package/dist/commands/propose.js +262 -0
  23. package/dist/commands/repositories.d.ts +18 -0
  24. package/dist/commands/repositories.js +185 -0
  25. package/dist/commands/setup.d.ts +75 -0
  26. package/dist/commands/setup.js +1471 -0
  27. package/dist/commands/status.d.ts +35 -0
  28. package/dist/commands/status.js +482 -0
  29. package/dist/commands/touch-map.d.ts +42 -0
  30. package/dist/commands/touch-map.js +251 -0
  31. package/dist/commands/whoami.d.ts +8 -0
  32. package/dist/commands/whoami.js +79 -0
  33. package/dist/conventions.d.ts +69 -0
  34. package/dist/conventions.js +175 -0
  35. package/dist/copy.d.ts +148 -0
  36. package/dist/copy.js +459 -0
  37. package/dist/currency.d.ts +31 -0
  38. package/dist/currency.js +72 -0
  39. package/dist/gh.d.ts +80 -0
  40. package/dist/gh.js +188 -0
  41. package/dist/git.d.ts +76 -0
  42. package/dist/git.js +203 -0
  43. package/dist/markers.d.ts +76 -0
  44. package/dist/markers.js +125 -0
  45. package/dist/mcp-config.d.ts +99 -0
  46. package/dist/mcp-config.js +230 -0
  47. package/dist/release.d.ts +55 -0
  48. package/dist/release.js +67 -0
  49. package/dist/repository.d.ts +8 -0
  50. package/dist/repository.js +32 -0
  51. package/dist/seals.d.ts +48 -0
  52. package/dist/seals.js +112 -0
  53. package/dist/store.d.ts +98 -0
  54. package/dist/store.js +225 -0
  55. package/dist/touch-map.d.ts +241 -0
  56. package/dist/touch-map.js +487 -0
  57. package/dist/wire.d.ts +588 -0
  58. package/dist/wire.js +20 -0
  59. package/package.json +19 -10
  60. package/bin/balladeer.js +0 -136
@@ -0,0 +1,35 @@
1
+ export type StatusOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ /**
5
+ * One promise, by id, instead of the whole picture.
6
+ *
7
+ * It is the second half of the line a person copies out of a broken promise:
8
+ * "Run balladeer status <id> first." An agent that starts here reads what the
9
+ * failing run actually reported and repairs the behavior; one that starts from
10
+ * the claim alone rewrites the test until it goes green.
11
+ */
12
+ promiseId?: string;
13
+ /** The repository whose connection to use, named as `owner/name`. */
14
+ repo?: string;
15
+ /** The repository whose connection to use, named by id, as `mcp` takes it. */
16
+ repository?: string;
17
+ environment: NodeJS.ProcessEnv;
18
+ cwd: string;
19
+ write: (text: string) => void;
20
+ }>;
21
+ /**
22
+ * What Balladeer looks like right now, read from the server and never from
23
+ * anything this machine remembers.
24
+ *
25
+ * It reads two different things over two different credentials, because they are
26
+ * two different questions. This repository's own state comes over its agent
27
+ * connection, which setup issued and which does not expire, so an agent asked
28
+ * "are we set up here" gets an answer months after pairing. The whole workspace,
29
+ * every repository in it, is a person's view and stays on the delegated setup
30
+ * session; when no live session is stored this says so rather than reporting a
31
+ * workspace it could not read.
32
+ *
33
+ * It changes nothing.
34
+ */
35
+ export declare function runStatus(options: StatusOptions): Promise<number>;
@@ -0,0 +1,482 @@
1
+ import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
2
+ import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
3
+ import { repositoryRoot } from "../git.js";
4
+ import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
5
+ import { commandLine } from "../release.js";
6
+ import { repositoryHint } from "../repository.js";
7
+ import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
8
+ import { StoreError, findSession, readCredentials } from "../store.js";
9
+ import {} from "../wire.js";
10
+ /**
11
+ * The bounded code for "this machine holds no credential for that repository".
12
+ * It is its own code rather than the session's: an agent branching on
13
+ * `session_expired` pairs again and still holds nothing.
14
+ */
15
+ const NO_CREDENTIAL_HERE = "no_agent_credential_on_this_machine";
16
+ function noSessionSentence(controlPlane) {
17
+ return `Every repository in the workspace is read over a setup session, and none is stored for ${controlPlane}. Run \`${commandLine(null, "setup")}\` to read the whole workspace.`;
18
+ }
19
+ /**
20
+ * What Balladeer looks like right now, read from the server and never from
21
+ * anything this machine remembers.
22
+ *
23
+ * It reads two different things over two different credentials, because they are
24
+ * two different questions. This repository's own state comes over its agent
25
+ * connection, which setup issued and which does not expire, so an agent asked
26
+ * "are we set up here" gets an answer months after pairing. The whole workspace,
27
+ * every repository in it, is a person's view and stays on the delegated setup
28
+ * session; when no live session is stored this says so rather than reporting a
29
+ * workspace it could not read.
30
+ *
31
+ * It changes nothing.
32
+ */
33
+ export async function runStatus(options) {
34
+ const code = await reportBalladeer(options);
35
+ // Last, and on every path out of the report above, including the ones that
36
+ // could read nothing at all. Which directories are sealed is a fact about
37
+ // this checkout rather than about any credential, and a session that has just
38
+ // been told it holds none is exactly the session about to edit something.
39
+ await reportSealedPaths(options, (step) => {
40
+ if (options.json)
41
+ options.write(`${JSON.stringify(step)}\n`);
42
+ }, (text) => {
43
+ if (!options.json)
44
+ options.write(`${text}\n`);
45
+ });
46
+ return code;
47
+ }
48
+ async function reportBalladeer(options) {
49
+ const emit = (step) => {
50
+ if (options.json)
51
+ options.write(`${JSON.stringify(step)}\n`);
52
+ };
53
+ const say = (text) => {
54
+ if (!options.json)
55
+ options.write(`${text}\n`);
56
+ };
57
+ const fail = (reason, message, exitCode) => {
58
+ if (options.json)
59
+ emit({ step: "error", reason, message, changed: false, exitCode });
60
+ else
61
+ options.write(`${message}\n`);
62
+ return exitCode;
63
+ };
64
+ let credentials;
65
+ try {
66
+ credentials = readCredentials(options.environment);
67
+ }
68
+ catch (error) {
69
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
70
+ }
71
+ const here = options.repo ?? repositoryHint(options.cwd);
72
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
73
+ const session = findSession(credentials, options.controlPlane);
74
+ // The credential this machine holds is what this repository is read over, so
75
+ // its absence is the first thing said, and it is said in its own words. What
76
+ // the founder got instead was the workspace half's refusal, "Balladeer refused
77
+ // to report this workspace (session_expired). Run setup to pair again", which
78
+ // named the wrong credential, the wrong remedy and the wrong machine: the
79
+ // repository was connected, in a browser, and this machine had never been
80
+ // issued anything.
81
+ if (selection.kind === "refused" && selection.missingFor !== undefined) {
82
+ const message = noAgentCredentialSentence(selection.missingFor);
83
+ if (options.json) {
84
+ emit({ step: "error", reason: NO_CREDENTIAL_HERE, message, changed: false, exitCode: 4 });
85
+ }
86
+ else {
87
+ options.write(`${message}\n`);
88
+ }
89
+ if (session === undefined) {
90
+ sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
91
+ }
92
+ else {
93
+ // A different question, over a different credential, so it is still
94
+ // answered underneath. Its outcome never becomes this run's exit code:
95
+ // this repository went unreported whatever the workspace read did.
96
+ await reportWorkspace(options, session, here, emit, say, true);
97
+ }
98
+ return 4;
99
+ }
100
+ if (selection.kind === "refused" && session === undefined) {
101
+ // Nothing stored can answer anything, so this names the form that always
102
+ // works: the one this copy was run as.
103
+ return fail("nothing_stored", `${selection.reason} No Balladeer setup session is stored for ${options.controlPlane} either. Run: ${commandLine(null, "setup")}`, 4);
104
+ }
105
+ // One promise, by id: a different question, answered over the same connection
106
+ // and on its own. Somebody who pasted the repair line wants what the failing
107
+ // run reported, not a repository-wide report they have to read past to find
108
+ // it, and the workspace-wide half needs a setup session this person may not
109
+ // have any more.
110
+ if (options.promiseId !== undefined) {
111
+ if (selection.kind !== "agent") {
112
+ return fail("nothing_stored", `${selection.reason} A promise is read over this repository's own agent connection, so nothing here could answer for ${options.promiseId}.`, 4);
113
+ }
114
+ return reportOnePromise(options, options.promiseId, selection.agent, emit, say);
115
+ }
116
+ let reported = false;
117
+ if (selection.kind === "agent") {
118
+ reported = await reportThisRepository(options, selection.agent, emit, say);
119
+ }
120
+ if (session === undefined) {
121
+ sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
122
+ return reported ? 0 : 4;
123
+ }
124
+ return reportWorkspace(options, session, here, emit, say, reported);
125
+ }
126
+ /**
127
+ * This repository, over its own agent connection.
128
+ *
129
+ * `get_promise_setup` is the tool the setup probe already uses, and it answers
130
+ * for the one repository the connection is bound to. An answer naming a
131
+ * different repository is a failure rather than a curiosity: it would mean this
132
+ * machine reported one repository's state under another's name.
133
+ */
134
+ async function reportThisRepository(options, agent, emit, say) {
135
+ const call = await callAgentTool(agent, "get_promise_setup", {});
136
+ if (call.kind !== "result") {
137
+ say(`This repository could not be read over its Balladeer agent connection: ${reason(call)}`);
138
+ return false;
139
+ }
140
+ const repositoryId = structuredString(call.structured, "repositoryId");
141
+ const repository = structuredString(call.structured, "repository");
142
+ if (repositoryId === undefined || repositoryId !== agent.repositoryId) {
143
+ say("That connection answered for a different repository, so nothing about this one was reported. Issue the connection again from the Balladeer setup page, and do not use it.");
144
+ return false;
145
+ }
146
+ const structured = call.structured;
147
+ const enrolled = structured.enrolled === true;
148
+ const memberCount = Array.isArray(structured.members) ? structured.members.length : 0;
149
+ emit({
150
+ step: "connection",
151
+ status: "agent",
152
+ repositoryId,
153
+ ...(repository === undefined ? {} : { repository }),
154
+ enrolled,
155
+ memberCount,
156
+ });
157
+ say(`${repository ?? repositoryId}, read over this repository's Balladeer agent connection.`);
158
+ say(" Agent: connected. This connection has no expiry; a person revokes it in Balladeer.");
159
+ say(` Attestor release: ${enrolled ? "enrolled, so CI has a runner to pin" : "not enrolled, so there is no runner to pin yet"}.`);
160
+ say(` People who may be named as a promise's owner: ${memberCount}.`);
161
+ await reportStaleMarkers(options, agent, emit, say);
162
+ return true;
163
+ }
164
+ /**
165
+ * The promises this repository keeps that name a path it no longer has.
166
+ *
167
+ * This is the one fact about a catalog that only the machine holding the
168
+ * checkout can establish. Balladeer stores the markers a person approved and
169
+ * has no way to look at the tree, so a rename that leaves a promise
170
+ * unretrievable is invisible everywhere except here. It is read over the same
171
+ * connection as the rest of this half, it changes nothing, and its failure is
172
+ * silence: a status run whose catalog could not be read still reported the
173
+ * repository.
174
+ */
175
+ async function reportStaleMarkers(options, agent, emit, say) {
176
+ const root = await repositoryRoot(options.cwd);
177
+ if (root === undefined)
178
+ return;
179
+ // One page, deliberately. The index is bounded and so is this: a status run
180
+ // is not the place to walk a thousand-promise catalog, and the row says when
181
+ // it stopped rather than reporting a clean tree it did not finish reading.
182
+ const call = await callAgentTool(agent, "list_promises", { limit: 100 });
183
+ if (call.kind !== "result")
184
+ return;
185
+ const structured = call.structured;
186
+ if (!Array.isArray(structured?.promises))
187
+ return;
188
+ const subjects = [];
189
+ for (const entry of structured.promises) {
190
+ if (typeof entry !== "object" || entry === null)
191
+ continue;
192
+ const row = entry;
193
+ if (typeof row.promiseId !== "string")
194
+ continue;
195
+ subjects.push({
196
+ promiseId: row.promiseId,
197
+ title: typeof row.title === "string" ? row.title : row.promiseId,
198
+ surfaces: Array.isArray(row.surfaces)
199
+ ? row.surfaces.filter((value) => typeof value === "string")
200
+ : [],
201
+ labels: Array.isArray(row.labels)
202
+ ? row.labels.filter((value) => typeof value === "string")
203
+ : [],
204
+ });
205
+ }
206
+ if (subjects.length === 0)
207
+ return;
208
+ const observed = observeMissingMarkers(root, subjects, MARKER_OBSERVATION_LIMITS);
209
+ const total = typeof structured.total === "number" ? structured.total : subjects.length;
210
+ emit({
211
+ step: "attention",
212
+ reason: "scope_marker_missing",
213
+ observed: observed.observations.length,
214
+ considered: subjects.length,
215
+ total,
216
+ stoppedEarly: observed.stoppedEarly,
217
+ promises: observed.observations.map((observation) => ({
218
+ promiseId: observation.promiseId,
219
+ missing: [...observation.missing],
220
+ })),
221
+ });
222
+ const row = staleMarkerRow(observed);
223
+ if (row !== undefined)
224
+ say(row);
225
+ if (subjects.length < total) {
226
+ say(` This looked at ${subjects.length} of ${total} promises, so there may be more paths to check.`);
227
+ }
228
+ }
229
+ /**
230
+ * One promise, read by id over this repository's agent connection.
231
+ *
232
+ * What it prints on a promise that is not holding is exactly what a repair
233
+ * starts from, and every line of it came off a stored row: the commit the
234
+ * failing run checked, the bounded reason it could not report green, and the
235
+ * cases in the meaning its owner agreed to that the run was checking. The note
236
+ * about what those cases are is printed with them rather than left in a
237
+ * document, because the one thing worse than not naming them is naming them in
238
+ * a way that reads as a verdict on each one.
239
+ *
240
+ * A promise that is holding says so in one line. It is not an error and does
241
+ * not exit non-zero: somebody who ran this after a fix wants to hear that the
242
+ * behavior is back, and a red exit would tell their agent to keep going.
243
+ */
244
+ async function reportOnePromise(options, promiseId, agent, emit, say) {
245
+ const call = await callAgentTool(agent, "get_promise", { promiseId });
246
+ if (call.kind !== "result") {
247
+ const message = `${promiseId} could not be read over this repository's Balladeer agent connection: ${reason(call)}`;
248
+ emit({ step: "promise", status: "unreadable", promiseId, message });
249
+ say(message);
250
+ return 5;
251
+ }
252
+ const structured = call.structured;
253
+ if (structured.found === false) {
254
+ const message = structuredString(call.structured, "reason") ??
255
+ `No current approved promise with the id ${promiseId} is available to this repository.`;
256
+ emit({ step: "promise", status: "unreadable", promiseId, message });
257
+ say(message);
258
+ return 4;
259
+ }
260
+ const title = structuredString(call.structured, "title");
261
+ const posture = structuredString(call.structured, "posture");
262
+ const ownerName = structuredString(call.structured, "ownerName");
263
+ const promiseUrl = structuredString(call.structured, "promiseUrl");
264
+ const threat = structured.threat;
265
+ const named = {
266
+ promiseId,
267
+ ...(title === undefined ? {} : { title }),
268
+ ...(posture === undefined ? {} : { protectionPosture: posture }),
269
+ ...(ownerName === undefined ? {} : { ownerName }),
270
+ ...(promiseUrl === undefined ? {} : { promiseUrl }),
271
+ };
272
+ if (threat === undefined) {
273
+ emit({ step: "promise", status: "holding", ...named });
274
+ say(`${title ?? promiseId} (${promiseId})`);
275
+ if (posture !== undefined)
276
+ say(` Balladeer reports this promise as ${posture}.`);
277
+ say(" Nothing is reported broken here, so there is nothing to repair.");
278
+ if (promiseUrl !== undefined)
279
+ say(` Promise page: ${promiseUrl}`);
280
+ return 0;
281
+ }
282
+ const kind = threat.kind === "refuted" ? "refuted" : "unknown";
283
+ const sourceSha = typeof threat.sourceSha === "string" ? threat.sourceSha : undefined;
284
+ const reasonCode = typeof threat.reasonCode === "string" ? threat.reasonCode : "unknown";
285
+ const caseIds = Array.isArray(threat.refutedCaseIds)
286
+ ? threat.refutedCaseIds.filter((value) => typeof value === "string")
287
+ : [];
288
+ const caseNote = typeof threat.refutedCaseNote === "string" ? threat.refutedCaseNote : undefined;
289
+ const observedAt = typeof threat.observedAt === "string" ? threat.observedAt : undefined;
290
+ emit({
291
+ step: "promise",
292
+ status: "not_holding",
293
+ ...named,
294
+ kind,
295
+ ...(sourceSha === undefined ? {} : { sourceSha }),
296
+ reasonCode,
297
+ refutedCaseIds: caseIds,
298
+ ...(caseNote === undefined ? {} : { refutedCaseNote: caseNote }),
299
+ ...(observedAt === undefined ? {} : { observedAt }),
300
+ });
301
+ say(`${title ?? promiseId} (${promiseId})`);
302
+ say(kind === "refuted"
303
+ ? " A verifier ran and the behavior no longer holds."
304
+ : " The check could not reach a verdict, so this behavior is unmeasured rather than broken.");
305
+ say(` Commit the failing run checked: ${sourceSha ?? "not recorded for this run"}`);
306
+ say(` Reason: ${reasonCode}`);
307
+ say(caseIds.length === 0
308
+ ? " Cases named by this run: none."
309
+ : ` Cases the run was checking: ${caseIds.join(", ")}`);
310
+ if (caseNote !== undefined)
311
+ say(` ${caseNote}`);
312
+ if (observedAt !== undefined)
313
+ say(` Observed: ${observedAt}`);
314
+ if (ownerName !== undefined)
315
+ say(` Owner: ${ownerName}`);
316
+ if (promiseUrl !== undefined)
317
+ say(` Promise page: ${promiseUrl}`);
318
+ return 0;
319
+ }
320
+ /**
321
+ * Which directories in this checkout are sealed, read off the customer's own
322
+ * sealed packages rather than asked of anybody.
323
+ *
324
+ * It is here because this is the command an agent runs before it plans, and a
325
+ * coder who learns after the fact that a rename swept a promise's directory has
326
+ * cost its owner an afternoon of qualifying it again. Offline, bounded, and
327
+ * silent in a repository that seals nothing: a line about zero sealed
328
+ * directories is noise in the twelve repositories that have none yet.
329
+ */
330
+ async function reportSealedPaths(options, emit, say) {
331
+ // The sealed directories are named from the repository root, so the answer
332
+ // must not depend on which directory somebody happened to run this from.
333
+ const sealed = sealedPromises((await repositoryRoot(options.cwd)) ?? options.cwd);
334
+ if (sealed.length === 0)
335
+ return;
336
+ const shown = sealed.slice(0, SEALED_PATHS_SHOWN);
337
+ emit({
338
+ step: "sealed_paths",
339
+ total: sealed.length,
340
+ shown: shown.map((promise) => ({
341
+ promiseId: promise.promiseId,
342
+ sealedPath: promise.sealedPath,
343
+ ...(promise.title === undefined ? {} : { title: promise.title }),
344
+ })),
345
+ });
346
+ say("");
347
+ say(sealed.length === 1
348
+ ? " One promise seals a directory in this checkout. Editing anything in it breaks the seal:"
349
+ : ` ${sealed.length} promises seal a directory in this checkout. Editing anything in one breaks its seal:`);
350
+ for (const promise of shown)
351
+ say(` ${promise.sealedPath}${promise.title === undefined ? "" : ` ${promise.title}`}`);
352
+ if (sealed.length > shown.length)
353
+ say(` and ${sealed.length - shown.length} more, not listed here.`);
354
+ say(" Run `balladeer check-seals` before you push to find out whether your change broke one.");
355
+ }
356
+ function reason(call) {
357
+ switch (call.kind) {
358
+ case "endpoint_refused":
359
+ return call.reason;
360
+ case "unreachable":
361
+ return "Balladeer could not be reached from this machine.";
362
+ case "client_too_old":
363
+ return `this copy of the command is too old for that Balladeer. Update it with: ${call.update}`;
364
+ case "unauthorized":
365
+ return "the connection was revoked, or the repository it was bound to is no longer enrolled.";
366
+ case "tool_refusal":
367
+ return call.text;
368
+ case "http":
369
+ return `Balladeer answered ${call.status}.`;
370
+ default:
371
+ return "Balladeer's answer was not a tool result.";
372
+ }
373
+ }
374
+ /**
375
+ * The workspace-wide half, named as missing rather than reported as empty.
376
+ *
377
+ * A run that reported this repository and then said nothing about the workspace
378
+ * would read as a workspace with one repository in it.
379
+ */
380
+ function sayWorkspaceUnavailable(emit, say, reasonCode, sentence) {
381
+ emit({ step: "workspace", status: "unavailable", reason: reasonCode, message: sentence });
382
+ say("");
383
+ say(sentence);
384
+ }
385
+ /** Every repository in the workspace, which only a setup session can read. */
386
+ async function reportWorkspace(options, session, here, emit, say, alreadyReported) {
387
+ let state;
388
+ try {
389
+ state = await request(options.controlPlane, {
390
+ method: "GET",
391
+ path: "/api/setup/v1/state",
392
+ bearer: session.token,
393
+ });
394
+ }
395
+ catch (error) {
396
+ const code = error instanceof ClientTooOldError
397
+ ? "client_too_old"
398
+ : error instanceof TransportError
399
+ ? "control_plane_unreachable"
400
+ : error instanceof RefusalError
401
+ ? error.code
402
+ : "setup_state_unreadable";
403
+ if (alreadyReported) {
404
+ // This repository has already been reported over its own connection, so
405
+ // the workspace-wide half is a view this run could not read rather than a
406
+ // command that failed.
407
+ sayWorkspaceUnavailable(emit, say, code, `Every repository in the workspace is read over a setup session, and the stored one could not be used (${code}). Run \`${commandLine(null, "setup")}\` to read the whole workspace.`);
408
+ return 0;
409
+ }
410
+ if (error instanceof ClientTooOldError) {
411
+ return failOutside(options, emit, code, error.message, 3);
412
+ }
413
+ if (error instanceof TransportError) {
414
+ return failOutside(options, emit, code, `Balladeer could not be reached at ${options.controlPlane}: ${error.message}. Nothing was changed.`, 5);
415
+ }
416
+ if (error instanceof RefusalError) {
417
+ return failOutside(options, emit, code, `Balladeer refused to report this workspace (${code}). Run \`${commandLine(null, "setup")}\` to pair again. Nothing was changed.`, 5);
418
+ }
419
+ return failOutside(options, emit, code, "Balladeer could not report this workspace.", 5);
420
+ }
421
+ const active = state.repositories.filter((repository) => repository.status === "active");
422
+ emit({
423
+ step: "receipt",
424
+ setup: {
425
+ complete: active.some((item) => item.agentConfigured && item.ciConfigured),
426
+ sentence: `${active.length} repositor${active.length === 1 ? "y" : "ies"} enrolled in ${state.workspace.name}.`,
427
+ },
428
+ firstPromise: { complete: false, sentence: "See each repository below." },
429
+ protection: { complete: false, sentence: "See each repository below." },
430
+ connection: {
431
+ complete: active.some((item) => item.agentConfigured),
432
+ sentence: "Proposing and reporting run over each repository's agent connection, which does not expire.",
433
+ },
434
+ repositories: active.map((repository) => ({
435
+ repository: repository.displayName,
436
+ agent: repository.agentConfigured ? "agent connected" : "no agent connection",
437
+ ci: repository.ciConfigured
438
+ ? `CI connected, ${repository.ciValidatedRunCount} authenticated run${repository.ciValidatedRunCount === 1 ? "" : "s"}`
439
+ : repository.ciIdentityRecorded
440
+ ? "CI identity recorded, waiting for the first authenticated run"
441
+ : "CI not recorded",
442
+ })),
443
+ });
444
+ if (options.json)
445
+ return 0;
446
+ const setupCommand = commandLine(state.client.publishedVersion, "setup");
447
+ say("");
448
+ say(`Workspace "${state.workspace.name}" (${state.workspace.slug}).`);
449
+ if (active.length === 0) {
450
+ say(`No repository is enrolled yet. Run \`${setupCommand}\` inside one.`);
451
+ return 0;
452
+ }
453
+ for (const repository of active) {
454
+ const mark = repository.displayName.toLowerCase() === here.toLowerCase() ? " (this one)" : "";
455
+ say(`\n${repository.displayName}${mark}`);
456
+ say(` Default branch: ${repository.defaultBranch}`);
457
+ say(` Agent: ${repository.agentConfigured ? "connected" : "not connected"}`);
458
+ say(` CI: ${repository.ciConfigured
459
+ ? `connected, ${repository.ciValidatedRunCount} authenticated run${repository.ciValidatedRunCount === 1 ? "" : "s"}, first observed ${repository.ciFirstValidatedAt ?? "at an earlier run"}`
460
+ : repository.ciIdentityRecorded
461
+ ? "identity recorded, waiting for the first authenticated run"
462
+ : "not recorded"}`);
463
+ // `candidateCount` counts only what is genuinely waiting for a person, so a
464
+ // promise somebody already agreed to is never also listed as a proposal
465
+ // awaiting them, and a rejected proposal is listed nowhere at all.
466
+ say(` Promises: ${repository.promiseCount} agreed, ${repository.candidateCount} proposed and awaiting a person.`);
467
+ if (repository.firstPromiseId !== null) {
468
+ say(` First promise: ${options.controlPlane}/promises/${repository.firstPromiseId}`);
469
+ }
470
+ if (repository.latestCandidateId !== null) {
471
+ say(` Newest proposal: ${options.controlPlane}/candidates/${repository.latestCandidateId}`);
472
+ }
473
+ }
474
+ return 0;
475
+ }
476
+ function failOutside(options, emit, reasonCode, message, exitCode) {
477
+ if (options.json)
478
+ emit({ step: "error", reason: reasonCode, message, changed: false, exitCode });
479
+ else
480
+ options.write(`${message}\n`);
481
+ return exitCode;
482
+ }
@@ -0,0 +1,42 @@
1
+ import { type TouchMap } from "../touch-map.js";
2
+ export type TouchMapOptions = Readonly<{
3
+ cwd: string;
4
+ json: boolean;
5
+ environment: NodeJS.ProcessEnv;
6
+ write: (text: string) => void;
7
+ /** The clock, so a test can pin the timestamp the document carries. */
8
+ now?: () => Date;
9
+ }>;
10
+ /**
11
+ * Where the promises are, which is not always the git root.
12
+ *
13
+ * A repository keeps its promises at its top level, so the git root is the
14
+ * answer almost every time. It is not the answer when somebody is standing in a
15
+ * sub-tree that carries its own `.continuity/` directory, which is how this
16
+ * repository's own dogfood packages are laid out, and running there and mapping
17
+ * the outer tree instead would answer a question nobody asked.
18
+ */
19
+ export declare function promiseRoot(cwd: string): Promise<string | undefined>;
20
+ /**
21
+ * The command that turns "which files does this verifier read" from a guess
22
+ * into a measurement.
23
+ *
24
+ * It runs every sealed promise in this checkout under V8's coverage recorder
25
+ * and writes what each one executed to `.continuity/touch-map.json`. The file
26
+ * stays on the customer's disk. Nothing here posts it, and the map is a list of
27
+ * a customer's own file names, which is exactly the thing the boundary copy
28
+ * promises Balladeer never receives.
29
+ */
30
+ export declare function runTouchMap(options: TouchMapOptions): Promise<number>;
31
+ /**
32
+ * Write the map, and leave the file alone when nothing about it changed.
33
+ *
34
+ * The timestamp is the only field that moves on every run, so comparing the
35
+ * document without it is what makes a second run a no-op. A command that
36
+ * rewrote a byte of a committed file every time it was asked a question would
37
+ * put a diff in front of somebody who changed nothing.
38
+ */
39
+ export declare function writeTouchMap(root: string, map: TouchMap): Readonly<{
40
+ changed: boolean;
41
+ bytes: number;
42
+ }>;