balladeer 0.0.5 → 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 -161
@@ -0,0 +1,395 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { callAgentTool, selectAgent, structuredString } from "../agent.js";
3
+ import { reviewLink } from "../client.js";
4
+ import { commandLine } from "../release.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import { StoreError, findSession, readCredentials } from "../store.js";
7
+ import {} from "../wire.js";
8
+ import { readTeachBackFile } from "./propose.js";
9
+ /**
10
+ * Ten promises at 64 KiB each, which is the per-promise cap `propose` already
11
+ * enforces. The whole file is measured before it is parsed, so a file nobody
12
+ * meant to hand this command is refused on its size rather than after a parser
13
+ * has walked it.
14
+ */
15
+ export const MAX_PROMISES = 10;
16
+ const MAX_CATALOG_BYTES = MAX_PROMISES * 64 * 1024;
17
+ /** The only key this command reads at the top level of a catalog file. */
18
+ const ALLOWED_CATALOG_KEYS = ["promises"];
19
+ /**
20
+ * The shape check that happens on the developer's machine, for a whole catalog.
21
+ *
22
+ * It is `readTeachBackFile` run over every entry rather than a second, looser
23
+ * gate written beside it. That matters more here than it does for one proposal:
24
+ * this command exists because an agent has just walked a repository's tests,
25
+ * pull requests, documents and reverts, and everything it read while doing that
26
+ * is exactly what Balladeer promises never to receive. One entry that carries a
27
+ * path, a transcript, a diff, or a stray key it picked up on the way is refused
28
+ * here, before anything leaves the machine.
29
+ *
30
+ * So it returns the exact objects to send rather than a verdict on the file, and
31
+ * one bad entry refuses the whole catalog. Sending the good half of a file
32
+ * somebody has to fix anyway would leave a person a review page they cannot
33
+ * finish and a second run that duplicates what already landed.
34
+ */
35
+ export function readCatalogFile(raw) {
36
+ if (Buffer.byteLength(raw, "utf8") > MAX_CATALOG_BYTES) {
37
+ return { ok: false, reason: `the file is larger than ${MAX_PROMISES * 64} KiB` };
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ }
43
+ catch {
44
+ return { ok: false, reason: "the file is not JSON" };
45
+ }
46
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
47
+ return {
48
+ ok: false,
49
+ reason: "the file is not a JSON object with a promises array in it",
50
+ };
51
+ }
52
+ const extra = Object.keys(parsed).filter((key) => !ALLOWED_CATALOG_KEYS.includes(key));
53
+ if (extra.length > 0) {
54
+ return {
55
+ ok: false,
56
+ reason: `it carries ${extra.join(", ")}, which a catalog does not have and this command will not send`,
57
+ };
58
+ }
59
+ const promises = parsed.promises;
60
+ if (!Array.isArray(promises)) {
61
+ return { ok: false, reason: "it has no promises array" };
62
+ }
63
+ if (promises.length === 0) {
64
+ return { ok: false, reason: "its promises array is empty, so there is nothing to propose" };
65
+ }
66
+ if (promises.length > MAX_PROMISES) {
67
+ return {
68
+ ok: false,
69
+ reason: `it carries ${promises.length} promises and the cap is ${MAX_PROMISES}, because a person has to read every one of them`,
70
+ };
71
+ }
72
+ const value = [];
73
+ for (const [index, entry] of promises.entries()) {
74
+ // Re-serialized rather than passed as an object, so the entry meets the very
75
+ // same guard a single-proposal file meets, including its size cap and its
76
+ // refusal of a key this command does not send.
77
+ const read = readTeachBackFile(JSON.stringify(entry));
78
+ if (!read.ok) {
79
+ return {
80
+ ok: false,
81
+ reason: `promise ${index + 1} of ${promises.length} is not one this command will send: ${read.reason}`,
82
+ };
83
+ }
84
+ value.push(read.value);
85
+ }
86
+ return { ok: true, value };
87
+ }
88
+ /**
89
+ * The repository a proposal is about, taken from the connection rather than from
90
+ * the file, exactly as `propose` takes it.
91
+ */
92
+ function scopedMeaning(meaning, repositoryId) {
93
+ const scope = meaning.scope;
94
+ if (scope === null || typeof scope !== "object" || Array.isArray(scope))
95
+ return meaning;
96
+ return { ...meaning, scope: { ...scope, repositoryId } };
97
+ }
98
+ /** The active members this connection may name as a promise's owner. */
99
+ function membersOf(structured) {
100
+ if (structured === null || typeof structured !== "object")
101
+ return [];
102
+ const members = structured.members;
103
+ if (!Array.isArray(members))
104
+ return [];
105
+ return members.flatMap((member) => {
106
+ const id = structuredString(member, "membershipId");
107
+ const name = structuredString(member, "displayName");
108
+ return id === undefined ? [] : [{ membershipId: id, displayName: name ?? id }];
109
+ });
110
+ }
111
+ /** How a refused member list reads, in the words each failure needs. */
112
+ function memberListRefusal(kind, controlPlane) {
113
+ switch (kind) {
114
+ case "unreachable":
115
+ return `Balladeer could not be reached at ${controlPlane}, so I could not read who may own these promises.`;
116
+ case "unauthorized":
117
+ return "Balladeer refused this repository's agent connection, so I could not read who may own these promises.";
118
+ default:
119
+ return "Balladeer could not tell me who may own these promises.";
120
+ }
121
+ }
122
+ /**
123
+ * The title of one promise, for the list a person reads back. Absent rather than
124
+ * guessed: a numbered line with no title is better than a line naming the wrong
125
+ * promise.
126
+ */
127
+ function titleOf(request) {
128
+ const title = request.teachBack.meaning.title;
129
+ return typeof title === "string" && title.trim().length > 0
130
+ ? title.trim().slice(0, 160)
131
+ : "untitled promise";
132
+ }
133
+ /**
134
+ * The one link that opens every promise this run filed.
135
+ *
136
+ * A person who has just been handed eight proposals should be handed one page,
137
+ * not eight. The ids travel in the query string because the batch is exactly
138
+ * these promises and nothing else: a link to the whole inbox would also open
139
+ * whatever was already waiting there, and a filter by repository would open a
140
+ * different set tomorrow.
141
+ */
142
+ export function batchReviewLink(controlPlane, candidateIds) {
143
+ return reviewLink(controlPlane, `candidates/review?ids=${candidateIds.join(",")}`);
144
+ }
145
+ /**
146
+ * Failures of the connection rather than of one promise, which is the
147
+ * difference between trying the next one and stopping.
148
+ *
149
+ * A revoked credential or a server nobody can reach answers the tenth promise
150
+ * exactly as it answered the first, so carrying on spends nine more timeouts to
151
+ * print the same sentence nine more times. The remaining promises are reported
152
+ * as not attempted instead, which is also what tells a person a second run has
153
+ * work left to do.
154
+ */
155
+ const FATAL_REASONS = [
156
+ "control_plane_unreachable",
157
+ "agent_connection_revoked",
158
+ "client_too_old",
159
+ "agent_endpoint_unsafe",
160
+ ];
161
+ /**
162
+ * A refusal about the state of the workspace rather than about the promise in
163
+ * hand, recognized without reading the server's words.
164
+ *
165
+ * The one that bites in practice is the open-proposal cap: a repository that
166
+ * already has proposals waiting refuses every promise in a catalog for the same
167
+ * reason, and printing that sentence ten times is not ten pieces of
168
+ * information. Matching on the message rather than on its wording keeps this
169
+ * working when the server rewords the refusal, and keeps a genuinely
170
+ * per-promise refusal, a malformed meaning, from stopping the run: two
171
+ * different promises get two different sentences.
172
+ */
173
+ function repeatsTheLastRefusal(refused, message) {
174
+ const previous = refused[refused.length - 1];
175
+ return previous !== undefined && previous.message === message;
176
+ }
177
+ /**
178
+ * Files a whole discovered catalog over this repository's own agent connection,
179
+ * every promise owned by the person who ran setup, and hands back one link.
180
+ *
181
+ * The owner is named explicitly on every proposal rather than left to a server
182
+ * default. Who owns a promise decides who may agree to it, and a catalog of ten
183
+ * promises waiting on nobody is a catalog nobody can finish; naming the person
184
+ * who ran setup is the one honest answer this command can give without asking
185
+ * anyone anything.
186
+ */
187
+ export async function runDiscover(options) {
188
+ const emit = (step) => {
189
+ if (options.json)
190
+ options.write(`${JSON.stringify(step)}\n`);
191
+ };
192
+ const fail = (reason, message, exitCode) => {
193
+ if (options.json)
194
+ emit({ step: "error", reason, message, changed: false, exitCode });
195
+ else
196
+ options.write(`${message}\n`);
197
+ return exitCode;
198
+ };
199
+ if (!options.file) {
200
+ return fail("usage", "Give a catalog file: balladeer discover --file <path>.", 4);
201
+ }
202
+ let raw;
203
+ try {
204
+ raw = readFileSync(options.file, "utf8");
205
+ }
206
+ catch {
207
+ return fail("catalog_unreadable", `I could not read ${options.file}.`, 4);
208
+ }
209
+ const catalog = readCatalogFile(raw);
210
+ if (!catalog.ok) {
211
+ return fail("catalog_malformed", `${options.file} is not a promise catalog: ${catalog.reason}. Nothing was sent.`, 4);
212
+ }
213
+ let credentials;
214
+ try {
215
+ credentials = readCredentials(options.environment);
216
+ }
217
+ catch (error) {
218
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
219
+ }
220
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
221
+ if (selection.kind === "refused") {
222
+ return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was sent.`, 4);
223
+ }
224
+ const { agent } = selection;
225
+ // Whoever ran setup here, or whoever the caller named instead. Read before
226
+ // anything is sent, so a catalog is never half filed against an owner
227
+ // Balladeer then refuses.
228
+ const paired = findSession(credentials, options.controlPlane);
229
+ const ownerId = options.owner ?? paired?.membershipId;
230
+ if (ownerId === undefined) {
231
+ return fail("no_owner", "I do not know who to name as the owner of these promises, so nothing was sent. " +
232
+ `Run \`${commandLine(null, "setup")}\` here again to pair this machine, which records who you are, or name the owner yourself with --owner <membership id> from the members list on the Balladeer MCP server's setup tool.`, 4);
233
+ }
234
+ const setup = await callAgentTool(agent, "get_promise_setup", {});
235
+ if (setup.kind !== "result") {
236
+ return fail("owner_unverified", `${memberListRefusal(setup.kind, options.controlPlane)} Nothing was sent.`, 5);
237
+ }
238
+ const members = membersOf(setup.structured);
239
+ const owner = members.find((member) => member.membershipId === ownerId);
240
+ if (owner === undefined) {
241
+ const known = members
242
+ .map((member) => ` ${member.membershipId} ${member.displayName}`)
243
+ .join("\n");
244
+ return fail("owner_not_a_member", `${ownerId} is not an active member of this workspace, so nothing was sent.\n Name one of these instead with --owner:\n${known}`, 4);
245
+ }
246
+ const filed = [];
247
+ const refused = [];
248
+ let stoppedAfter;
249
+ for (const [index, request] of catalog.value.entries()) {
250
+ const outcome = await proposeOne(agent, request, ownerId);
251
+ const title = titleOf(request);
252
+ if (outcome.ok) {
253
+ const entry = {
254
+ index: index + 1,
255
+ title,
256
+ candidateId: outcome.candidateId,
257
+ reviewUrl: reviewLink(options.controlPlane, `candidates/${outcome.candidateId}`),
258
+ };
259
+ filed.push(entry);
260
+ emit({
261
+ step: "promise",
262
+ status: "proposed",
263
+ candidateId: entry.candidateId,
264
+ reviewUrl: entry.reviewUrl,
265
+ });
266
+ continue;
267
+ }
268
+ const twice = repeatsTheLastRefusal(refused, outcome.message);
269
+ refused.push({ index: index + 1, title, reason: outcome.reason, message: outcome.message });
270
+ if (twice || FATAL_REASONS.includes(outcome.reason)) {
271
+ stoppedAfter = index + 1;
272
+ break;
273
+ }
274
+ }
275
+ const notAttempted = stoppedAfter === undefined ? 0 : catalog.value.length - stoppedAfter;
276
+ const reviewUrl = batchReviewLink(options.controlPlane, filed.map((entry) => entry.candidateId));
277
+ emit({
278
+ step: "discovery",
279
+ status: refused.length === 0 ? "proposed" : "partial",
280
+ proposed: filed.length,
281
+ requested: catalog.value.length,
282
+ ownerId,
283
+ candidateIds: filed.map((entry) => entry.candidateId),
284
+ ...(filed.length === 0 ? {} : { reviewUrl }),
285
+ ...(notAttempted === 0 ? {} : { notAttempted }),
286
+ ...(refused.length === 0
287
+ ? {}
288
+ : {
289
+ failures: refused.map((entry) => ({
290
+ promise: entry.index,
291
+ reason: entry.reason,
292
+ message: entry.message,
293
+ })),
294
+ }),
295
+ });
296
+ if (!options.json) {
297
+ const where = agent.repository ?? "this repository";
298
+ if (filed.length > 0) {
299
+ options.write(`Proposed ${filed.length} of ${catalog.value.length} promises from ${where}, owned by ${owner.displayName}.\n`);
300
+ for (const entry of filed)
301
+ options.write(` ${entry.index}. ${entry.title}\n`);
302
+ options.write(`Read all ${filed.length} on one page: ${reviewUrl}\n`);
303
+ options.write(`${owner.displayName} reads each one and clicks Agree. Nothing this command can run agrees to one.\n`);
304
+ }
305
+ for (const entry of refused) {
306
+ options.write(`Promise ${entry.index}, ${entry.title}, was not recorded: ${entry.message}\n`);
307
+ }
308
+ if (notAttempted > 0) {
309
+ options.write(`I stopped there: the remaining ${notAttempted} ${notAttempted === 1 ? "promise was" : "promises were"} not sent, because that failure would answer every one of them the same way. Run this again once it is fixed.\n`);
310
+ }
311
+ if (filed.length > 0) {
312
+ options.write(`Sent over ${where}'s Balladeer agent connection, which does not expire.\n`);
313
+ }
314
+ }
315
+ return refused.length === 0 ? 0 : 5;
316
+ }
317
+ /**
318
+ * One promise, over the same tool call `propose` makes, with the owner named.
319
+ *
320
+ * Nothing here reads the file again: it sends the object the catalog guard
321
+ * returned, with the repository stamped from the connection.
322
+ */
323
+ async function proposeOne(agent, request, ownerId) {
324
+ const call = await callAgentTool(agent, "propose_promise", {
325
+ explicitHumanAction: true,
326
+ source: "protect_behavior",
327
+ explicitIntent: request.explicitIntent,
328
+ meaning: scopedMeaning(request.teachBack.meaning, agent.repositoryId),
329
+ unresolvedQuestions: request.teachBack.unresolvedQuestions ?? [],
330
+ // The person who ran setup, named on every promise. A file that named
331
+ // somebody else loses that argument: the catalog is waiting on one person,
332
+ // and a promise waiting on another is one nobody watches.
333
+ proposedOwnerId: ownerId,
334
+ // Read from the file, exactly as `propose` reads it. A discovery run is
335
+ // where a hedged number matters most: nine promises drawn from nine sources
336
+ // are not nine equally certain readings, and an owner deciding which to read
337
+ // first is entitled to see which one the agent was least sure of.
338
+ confidence: request.teachBack.confidence,
339
+ });
340
+ switch (call.kind) {
341
+ case "endpoint_refused":
342
+ return { ok: false, reason: "agent_endpoint_unsafe", message: call.reason };
343
+ case "unreachable":
344
+ return {
345
+ ok: false,
346
+ reason: "control_plane_unreachable",
347
+ message: "Balladeer could not be reached, and nothing was recorded for it.",
348
+ };
349
+ case "client_too_old":
350
+ return {
351
+ ok: false,
352
+ reason: "client_too_old",
353
+ message: `this copy of the Balladeer command is too old for the server. Update it with: ${call.update}`,
354
+ };
355
+ case "unauthorized":
356
+ return {
357
+ ok: false,
358
+ reason: "agent_connection_revoked",
359
+ message: "Balladeer refused this repository's agent connection: it was revoked, or the repository it was bound to is no longer enrolled. Retrying will not help.",
360
+ };
361
+ case "tool_refusal":
362
+ return {
363
+ ok: false,
364
+ reason: "propose_refused",
365
+ message: `Balladeer refused it: ${call.text}`,
366
+ };
367
+ case "http":
368
+ return {
369
+ ok: false,
370
+ reason: "propose_failed",
371
+ message: `Balladeer answered ${call.status}, and nothing was recorded for it.`,
372
+ };
373
+ case "malformed":
374
+ return {
375
+ ok: false,
376
+ reason: "propose_failed",
377
+ message: "Balladeer could not record it.",
378
+ };
379
+ case "result":
380
+ break;
381
+ }
382
+ const candidateId = structuredString(call.structured, "id");
383
+ const status = structuredString(call.structured, "status");
384
+ if (candidateId === undefined) {
385
+ return { ok: false, reason: "propose_failed", message: "Balladeer could not record it." };
386
+ }
387
+ if (status !== "pending_review") {
388
+ return {
389
+ ok: false,
390
+ reason: "candidate_not_pending",
391
+ message: `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`,
392
+ };
393
+ }
394
+ return { ok: true, candidateId };
395
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What this release of the command actually performs, stamped with its own
3
+ * version and kept strictly outside the boundary copy.
4
+ *
5
+ * The boundary copy describes the product's boundary and is timeless; it names
6
+ * two credentials, a CI workflow, and a check on pull requests. In balladeer
7
+ * 1.0.0 the command reaches none of those, so saying so is the difference
8
+ * between an explanation and a claim.
9
+ */
10
+ export declare const RELEASE_PREFACE: string;
11
+ /**
12
+ * Where a person watches this after the terminal has scrolled away.
13
+ *
14
+ * The boundary copy says what Balladeer receives and the release preface says
15
+ * what the command does; neither of them ever said where any of it shows up.
16
+ * Somebody who has just agreed to their first promise and closed the terminal
17
+ * had, until this paragraph, no way to find it again from anything the product
18
+ * printed. The address is the deployment this command is talking to rather than
19
+ * a literal, because a person running against a staging deployment reading a
20
+ * production link would be sent to somebody else's workspace.
21
+ */
22
+ export declare function whereToWatch(controlPlane: string): string;
23
+ /**
24
+ * What leaving costs, answered before anybody has to ask.
25
+ *
26
+ * A person deciding whether to put their team's promises somewhere is deciding
27
+ * how hard it would be to stop, and the honest answer here is unusually good:
28
+ * the tests are theirs and stay theirs. It was written down in
29
+ * `docs/customer-operability.md` and reachable from no command, so the one
30
+ * audience who most needs it, the engineer being asked to try this, never met
31
+ * it.
32
+ */
33
+ export declare function ifYouStop(controlPlane: string): string;
34
+ export declare function explainText(controlPlane?: string): string;
35
+ export declare function runExplain(write: (text: string) => void, controlPlane?: string): number;
@@ -0,0 +1,90 @@
1
+ import { BOUNDARY_COPY } from "../copy.js";
2
+ import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "../wire.js";
3
+ /**
4
+ * What this release of the command actually performs, stamped with its own
5
+ * version and kept strictly outside the boundary copy.
6
+ *
7
+ * The boundary copy describes the product's boundary and is timeless; it names
8
+ * two credentials, a CI workflow, and a check on pull requests. In balladeer
9
+ * 1.0.0 the command reaches none of those, so saying so is the difference
10
+ * between an explanation and a claim.
11
+ */
12
+ export const RELEASE_PREFACE = [
13
+ `In balladeer ${CLI_VERSION} this command performs all five steps: it pairs this session, adds`,
14
+ `this repository, connects this coding agent, records the CI identity and opens the pull request`,
15
+ `whose run proves it, and hands you the playbook for discovering this repository's own promises.`,
16
+ `The two things it never does are sign you in and agree to a promise; both of those are yours,`,
17
+ `in a browser.`,
18
+ ].join("\n");
19
+ /**
20
+ * Where a person watches this after the terminal has scrolled away.
21
+ *
22
+ * The boundary copy says what Balladeer receives and the release preface says
23
+ * what the command does; neither of them ever said where any of it shows up.
24
+ * Somebody who has just agreed to their first promise and closed the terminal
25
+ * had, until this paragraph, no way to find it again from anything the product
26
+ * printed. The address is the deployment this command is talking to rather than
27
+ * a literal, because a person running against a staging deployment reading a
28
+ * production link would be sent to somebody else's workspace.
29
+ */
30
+ export function whereToWatch(controlPlane) {
31
+ return [
32
+ "Where to watch this",
33
+ "",
34
+ `Everything Balladeer holds for your team is at ${controlPlane}. The catalog lists every promise`,
35
+ "with who owns it and whether anything is checking it. Each promise has its own page, which is",
36
+ "the one to keep: it shows what your team agreed the software must do, in their words, and then",
37
+ "every run that has checked it since, newest first, with the commit each one checked. That page",
38
+ "is how a promise reads over time rather than today, and it is where a broken one says what",
39
+ "broke and when.",
40
+ "",
41
+ "Balladeer also has a Slack app, and a workspace administrator installs it from workspace",
42
+ "settings. Once it is installed, the person who owns a promise gets a direct message when their",
43
+ "promise goes live, when a run on your default branch breaks it, and once a week about promises",
44
+ "nothing is checking yet. Until an administrator installs it, nothing is sent anywhere and the",
45
+ "product is somewhere you look rather than something that tells you.",
46
+ ].join("\n");
47
+ }
48
+ /**
49
+ * What leaving costs, answered before anybody has to ask.
50
+ *
51
+ * A person deciding whether to put their team's promises somewhere is deciding
52
+ * how hard it would be to stop, and the honest answer here is unusually good:
53
+ * the tests are theirs and stay theirs. It was written down in
54
+ * `docs/customer-operability.md` and reachable from no command, so the one
55
+ * audience who most needs it, the engineer being asked to try this, never met
56
+ * it.
57
+ */
58
+ export function ifYouStop(controlPlane) {
59
+ return [
60
+ "If you stop using Balladeer",
61
+ "",
62
+ "Your tests are yours and they keep running. The verifier package, its fixtures and the workflow",
63
+ "file all live in your own repository, they run in your own CI, and disconnecting Balladeer",
64
+ "changes nothing about any of them: nothing here is a dependency your test suite acquires.",
65
+ "",
66
+ `An administrator can download everything Balladeer holds for your team at any time, at`,
67
+ `${controlPlane}/settings: every promise and what it means, who agreed to it and when, the`,
68
+ "verifier each one is bound to, every run that reported, and the history of every change. It is",
69
+ "one JSON file and it needs nobody's permission but your own.",
70
+ "",
71
+ "Disconnecting produces that same download in the response that ends hosted access, so nobody",
72
+ "ends up locked out of their own history by the act of leaving. After it, every credential this",
73
+ "setup issued stops working and Balladeer stops accepting results. Your repository is untouched.",
74
+ ].join("\n");
75
+ }
76
+ export function explainText(controlPlane = DEFAULT_CONTROL_PLANE) {
77
+ return [
78
+ BOUNDARY_COPY,
79
+ RELEASE_PREFACE,
80
+ "",
81
+ whereToWatch(controlPlane),
82
+ "",
83
+ ifYouStop(controlPlane),
84
+ "",
85
+ ].join("\n");
86
+ }
87
+ export function runExplain(write, controlPlane = DEFAULT_CONTROL_PLANE) {
88
+ write(explainText(controlPlane));
89
+ return 0;
90
+ }
@@ -0,0 +1,24 @@
1
+ export type InviteRole = "admin" | "contributor" | "viewer";
2
+ export type InviteOptions = Readonly<{
3
+ controlPlane: string;
4
+ json: boolean;
5
+ emails: readonly string[];
6
+ role: InviteRole;
7
+ environment: NodeJS.ProcessEnv;
8
+ write: (text: string) => void;
9
+ }>;
10
+ export declare const ROLE_NAMES: Readonly<Record<InviteRole, string>>;
11
+ /** What each role may do, in one clause, so nobody invites by guessing. */
12
+ export declare const ROLE_MEANINGS: Readonly<Record<InviteRole, string>>;
13
+ export declare function parseRole(value: string | undefined): InviteRole | undefined;
14
+ export declare function looksLikeEmail(value: string): boolean;
15
+ /**
16
+ * Invites teammates by email, one at a time, and says what happened to each.
17
+ *
18
+ * Per address rather than per run. Sending four invitations is four separate
19
+ * things that can each succeed or be refused on their own, and a person reading
20
+ * "3 of 4 sent" cannot tell whose email is missing. Every address gets its own
21
+ * line and its own object, and the exit code says only whether every one of
22
+ * them went.
23
+ */
24
+ export declare function runInvite(options: InviteOptions): Promise<number>;