balladeer 0.0.5 → 1.0.1

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 (72) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +167 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +48 -0
  6. package/dist/cli.js +531 -0
  7. package/dist/client.d.ts +66 -0
  8. package/dist/client.js +142 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +123 -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 +403 -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 +198 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/prepare.d.ts +74 -0
  22. package/dist/commands/prepare.js +217 -0
  23. package/dist/commands/propose.d.ts +69 -0
  24. package/dist/commands/propose.js +284 -0
  25. package/dist/commands/repositories.d.ts +18 -0
  26. package/dist/commands/repositories.js +185 -0
  27. package/dist/commands/session.d.ts +35 -0
  28. package/dist/commands/session.js +118 -0
  29. package/dist/commands/setup.d.ts +98 -0
  30. package/dist/commands/setup.js +1600 -0
  31. package/dist/commands/status.d.ts +51 -0
  32. package/dist/commands/status.js +542 -0
  33. package/dist/commands/touch-map.d.ts +42 -0
  34. package/dist/commands/touch-map.js +251 -0
  35. package/dist/commands/whoami.d.ts +8 -0
  36. package/dist/commands/whoami.js +80 -0
  37. package/dist/conventions.d.ts +77 -0
  38. package/dist/conventions.js +183 -0
  39. package/dist/copy.d.ts +224 -0
  40. package/dist/copy.js +641 -0
  41. package/dist/currency.d.ts +31 -0
  42. package/dist/currency.js +72 -0
  43. package/dist/desktop-config.d.ts +85 -0
  44. package/dist/desktop-config.js +217 -0
  45. package/dist/gh.d.ts +80 -0
  46. package/dist/gh.js +188 -0
  47. package/dist/git.d.ts +91 -0
  48. package/dist/git.js +226 -0
  49. package/dist/legacy.d.ts +41 -0
  50. package/dist/legacy.js +143 -0
  51. package/dist/local-time.d.ts +66 -0
  52. package/dist/local-time.js +84 -0
  53. package/dist/markers.d.ts +76 -0
  54. package/dist/markers.js +125 -0
  55. package/dist/mcp-config.d.ts +109 -0
  56. package/dist/mcp-config.js +234 -0
  57. package/dist/release.d.ts +55 -0
  58. package/dist/release.js +67 -0
  59. package/dist/repository.d.ts +8 -0
  60. package/dist/repository.js +32 -0
  61. package/dist/seals.d.ts +48 -0
  62. package/dist/seals.js +112 -0
  63. package/dist/session.d.ts +84 -0
  64. package/dist/session.js +135 -0
  65. package/dist/store.d.ts +108 -0
  66. package/dist/store.js +237 -0
  67. package/dist/touch-map.d.ts +241 -0
  68. package/dist/touch-map.js +487 -0
  69. package/dist/wire.d.ts +674 -0
  70. package/dist/wire.js +20 -0
  71. package/package.json +19 -10
  72. package/bin/balladeer.js +0 -161
@@ -0,0 +1,217 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
4
+ import { inReadersZone } from "../local-time.js";
5
+ import { commandLine } from "../release.js";
6
+ import { repositoryHint } from "../repository.js";
7
+ import { StoreError, readCredentials } from "../store.js";
8
+ import {} from "../wire.js";
9
+ const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
10
+ /**
11
+ * The tool's argument, as the person in front of this terminal would type it.
12
+ *
13
+ * `prepare_qualification` takes `again: true`, and its refusal says so, which is
14
+ * right for the agent that called the tool and wrong for the reader of this
15
+ * command: `balladeer prepare <id> again` is a usage error, and the flag is
16
+ * `--again`. The sentence is relayed verbatim otherwise, so this is the one word
17
+ * in it that has two correct spellings depending on who is reading.
18
+ */
19
+ const TOOL_ARGUMENT_FOR_AGAIN = /`again(?::\s*true)?`/g;
20
+ /**
21
+ * A refusal Balladeer wrote, as this terminal has to say it.
22
+ *
23
+ * Two changes and no others. The instants move onto the reader's own clock,
24
+ * because the server writes UTC and "already prepared by Robert Clark on
25
+ * 2026-09-07T18:05:10.698Z" reached somebody whose clock said two in the
26
+ * afternoon. And the argument is named the way this command takes it. Every
27
+ * other word, including what to do next and why, is the server's.
28
+ */
29
+ export function atThisTerminal(text) {
30
+ return inReadersZone(text).replace(TOOL_ARGUMENT_FOR_AGAIN, "`--again`");
31
+ }
32
+ /**
33
+ * The six keys the sealed run reads, and nothing else.
34
+ *
35
+ * The runner rejects an unknown field outright, so a metadata file that carried
36
+ * a helpful comment, a timestamp, or the whole packet would fail the customer's
37
+ * protected run for a reason that had nothing to do with their behavior. This
38
+ * command therefore writes the packet's own `qualificationMetadata` object and
39
+ * refuses to write anything it does not recognise.
40
+ */
41
+ const METADATA_KEYS = [
42
+ "schemaVersion",
43
+ "workspaceLocator",
44
+ "receiptId",
45
+ "revisionId",
46
+ "bindingId",
47
+ "workflowDigest",
48
+ ];
49
+ export function readQualificationMetadata(structured) {
50
+ const packet = structured !== null && typeof structured === "object"
51
+ ? structured.packet
52
+ : undefined;
53
+ const metadata = packet !== null && typeof packet === "object"
54
+ ? packet.qualificationMetadata
55
+ : undefined;
56
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
57
+ return { ok: false, reason: "the answer carried no qualification metadata" };
58
+ }
59
+ const record = metadata;
60
+ const unexpected = Object.keys(record).filter((key) => !METADATA_KEYS.includes(key));
61
+ if (unexpected.length > 0) {
62
+ return {
63
+ ok: false,
64
+ reason: `the metadata carries ${unexpected.join(", ")}, which the sealed run refuses, so nothing was written`,
65
+ };
66
+ }
67
+ const missing = METADATA_KEYS.filter((key) => typeof record[key] !== "string");
68
+ if (missing.length > 0) {
69
+ return { ok: false, reason: `the metadata is missing ${missing.join(", ")}` };
70
+ }
71
+ return {
72
+ ok: true,
73
+ value: Object.fromEntries(METADATA_KEYS.map((key) => [key, record[key]])),
74
+ };
75
+ }
76
+ /**
77
+ * Where the file goes, refusing any path that would leave this repository.
78
+ *
79
+ * The path comes off the server's answer, and the whole point of this command is
80
+ * that it writes a file the person did not type. A server that answered with
81
+ * `../../etc/something` would otherwise have this command write there, so the
82
+ * resolved path has to stay under the directory the command was run in.
83
+ */
84
+ export function metadataDestination(cwd, metadataPath) {
85
+ if (isAbsolute(metadataPath)) {
86
+ return { ok: false, reason: "the path Balladeer answered with is absolute" };
87
+ }
88
+ const root = resolve(cwd);
89
+ const destination = resolve(join(root, metadataPath));
90
+ const inside = relative(root, destination);
91
+ if (inside.startsWith("..") || isAbsolute(inside)) {
92
+ return { ok: false, reason: "the path Balladeer answered with leaves this repository" };
93
+ }
94
+ return { ok: true, path: destination };
95
+ }
96
+ function stringList(structured, field) {
97
+ if (structured === null || typeof structured !== "object")
98
+ return [];
99
+ const value = structured[field];
100
+ return Array.isArray(value)
101
+ ? value.filter((item) => typeof item === "string")
102
+ : [];
103
+ }
104
+ /**
105
+ * Prepares the one-time qualification setup for one promise, and writes the file
106
+ * where the sealed run reads it.
107
+ *
108
+ * It runs over this repository's own agent connection, which is what makes it
109
+ * usable at all: preparing a qualification setup is the agent's job, and the
110
+ * setup session that paired the machine expires. There is no sign-off here and
111
+ * no code to paste. Agreeing the meaning was the person's act; building the
112
+ * check that proves it is this command's caller's.
113
+ *
114
+ * The file is written once. A second run while nobody has published against the
115
+ * first packet is refused, naming who prepared it and when, and `--again` mints
116
+ * a replacement that invalidates the earlier one on the server as well as
117
+ * overwriting the file here.
118
+ */
119
+ export async function runPrepare(options) {
120
+ const emit = (step) => {
121
+ if (options.json)
122
+ options.write(`${JSON.stringify(step)}\n`);
123
+ };
124
+ const fail = (reason, message, exitCode) => {
125
+ if (options.json)
126
+ emit({ step: "error", reason, message, changed: false, exitCode });
127
+ else
128
+ options.write(`${message}\n`);
129
+ return exitCode;
130
+ };
131
+ if (!PROMISE_ID.test(options.promiseId)) {
132
+ return fail("usage", `"${options.promiseId}" is not a promise id. A promise id looks like prom_ followed by letters and digits, and every promise page has a control that copies its own.`, 4);
133
+ }
134
+ let credentials;
135
+ try {
136
+ credentials = readCredentials(options.environment);
137
+ }
138
+ catch (error) {
139
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
140
+ }
141
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
142
+ if (selection.kind === "refused") {
143
+ if (selection.missingFor !== undefined) {
144
+ return fail("no_agent_credential_on_this_machine", `${noAgentCredentialSentence(selection.missingFor)} Nothing was prepared.`, 4);
145
+ }
146
+ return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was prepared.`, 4);
147
+ }
148
+ const { agent } = selection;
149
+ const call = await callAgentTool(agent, "prepare_qualification", {
150
+ promiseId: options.promiseId,
151
+ again: options.again,
152
+ });
153
+ switch (call.kind) {
154
+ case "endpoint_refused":
155
+ return fail("agent_endpoint_unsafe", `${call.reason} Nothing was prepared.`, 4);
156
+ case "unreachable":
157
+ return fail("control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}. Nothing was prepared.`, 5);
158
+ case "client_too_old":
159
+ return fail("client_too_old", `This copy of the Balladeer command is too old for ${options.controlPlane}. Update it with: ${call.update}`, 3);
160
+ case "unauthorized":
161
+ return fail("agent_connection_revoked", `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. Run \`${commandLine(null, "setup")}\` here again, or issue a new connection at ${options.controlPlane}/setup. Nothing was prepared.`, 5);
162
+ case "tool_refusal":
163
+ // The server's own sentence, which names what to do next: use the packet
164
+ // that already exists, wait for the owner to agree, or run this again with
165
+ // --again. Rewriting it here would lose the part the person has to read,
166
+ // so `atThisTerminal` changes only the two things the server could not
167
+ // know: which clock the reader is on, and that they type a flag.
168
+ return fail("prepare_refused", `Balladeer refused this: ${atThisTerminal(call.text)}`, 5);
169
+ case "http":
170
+ return fail("prepare_failed", `Balladeer answered ${call.status} to this request. Nothing was prepared.`, 5);
171
+ case "malformed":
172
+ return fail("prepare_failed", "Balladeer could not prepare a qualification setup.", 5);
173
+ case "result":
174
+ break;
175
+ }
176
+ const structured = call.structured;
177
+ const metadataPath = typeof structured.metadataPath === "string" ? structured.metadataPath : "";
178
+ if (metadataPath === "") {
179
+ return fail("prepare_failed", "Balladeer could not prepare a qualification setup.", 5);
180
+ }
181
+ const destination = metadataDestination(options.cwd, metadataPath);
182
+ if (!destination.ok) {
183
+ // Minted on the server and not written here. Said plainly rather than
184
+ // silently: the packet is one-time, so somebody has to know it was spent.
185
+ return fail("metadata_path_unsafe", `Balladeer prepared the qualification setup, but ${destination.reason}, so nothing was written to disk. Nothing else was changed.`, 5);
186
+ }
187
+ const metadata = readQualificationMetadata(call.structured);
188
+ if (!metadata.ok) {
189
+ return fail("metadata_unusable", `Balladeer prepared the qualification setup, but ${metadata.reason}, so nothing was written to disk.`, 5);
190
+ }
191
+ try {
192
+ mkdirSync(dirname(destination.path), { recursive: true });
193
+ writeFileSync(destination.path, `${JSON.stringify(metadata.value, null, 2)}\n`, "utf8");
194
+ }
195
+ catch (error) {
196
+ return fail("metadata_unwritable", `Balladeer prepared the qualification setup, but ${metadataPath} could not be written: ${error instanceof Error ? error.message : String(error)}`, 4);
197
+ }
198
+ const superseded = typeof structured.supersededPackets === "number" ? structured.supersededPackets : 0;
199
+ emit({
200
+ step: "qualification",
201
+ status: "prepared",
202
+ promiseId: options.promiseId,
203
+ metadataPath,
204
+ supersededPackets: superseded,
205
+ });
206
+ if (!options.json) {
207
+ options.write(`${metadataPath}\n`);
208
+ if (superseded > 0) {
209
+ options.write(`The qualification setup prepared earlier is no longer valid: a run that publishes its identities is refused.\n`);
210
+ }
211
+ options.write("Next: build this promise's verifier, seal it, and push to the default branch.\n");
212
+ options.write("Protection starts by itself when that run qualifies. Nobody activates anything, and this file is removed in an ordinary follow-up change once the receipt appears.\n");
213
+ for (const step of stringList(call.structured, "nextSteps"))
214
+ options.write(` ${step}\n`);
215
+ }
216
+ return 0;
217
+ }
@@ -0,0 +1,69 @@
1
+ export type ProposeOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ file?: string;
5
+ /** The repository whose connection to use, named as `owner/name`. */
6
+ repo?: string;
7
+ /** The repository whose connection to use, named by id, as `mcp` takes it. */
8
+ repository?: string;
9
+ environment: NodeJS.ProcessEnv;
10
+ cwd: string;
11
+ write: (text: string) => void;
12
+ }>;
13
+ export type TeachBackRequest = Readonly<{
14
+ explicitIntent: string;
15
+ teachBack: Readonly<{
16
+ meaning: Record<string, unknown>;
17
+ unresolvedQuestions?: unknown;
18
+ proposedOwnerId?: unknown;
19
+ /** How sure whoever wrote this file was, 0 to 1. Read, never assumed. */
20
+ confidence: number;
21
+ /**
22
+ * What going wrong looks like, in the words the person used. Required, and
23
+ * refused here as well as by the server: a behavior with no sentence of that
24
+ * shape has no failing case a check could ever catch, and a promise that can
25
+ * never fail is worse than no promise at all.
26
+ */
27
+ wrongOutcome: string;
28
+ /** The one thing least certain, which the review page reads instead of the
29
+ * confidence number. Optional: silence here means nothing was said. */
30
+ leastSure?: unknown;
31
+ }>;
32
+ }>;
33
+ /**
34
+ * The shape check that happens on the developer's machine, and the body it
35
+ * builds.
36
+ *
37
+ * `--file` reads whatever path it is handed, and an agent that mistypes one
38
+ * would otherwise upload a `.env`, a source file, or a log to a service whose
39
+ * whole boundary is that it never receives those. Refusing here means the wrong
40
+ * file never leaves the machine, rather than leaving it and being refused.
41
+ *
42
+ * So this returns the exact object to send rather than a verdict on the file.
43
+ * Spreading the parsed file into the request was the same mistake in a quieter
44
+ * form: whatever else the file happened to carry travelled with it, and the
45
+ * strict schema on the other side is a refusal after the fact, not a boundary.
46
+ */
47
+ export declare function readTeachBackFile(raw: string): Readonly<{
48
+ ok: true;
49
+ value: TeachBackRequest;
50
+ }> | Readonly<{
51
+ ok: false;
52
+ reason: string;
53
+ }>;
54
+ /**
55
+ * Proposes one promise over this repository's own agent connection.
56
+ *
57
+ * It used to go over the delegated setup session, which lives for a bounded time
58
+ * and is meant for setting Balladeer up. That made proposing something a person
59
+ * could only do for a while after pairing: the credential expired, and a command
60
+ * whose whole job is "propose this" answered "run setup again". The connection
61
+ * this uses instead is the one setup issued for this repository, the same one
62
+ * the agent host forwards, and it does not expire.
63
+ *
64
+ * Nothing widens by moving: the call is `propose_promise` on the MCP endpoint,
65
+ * so it is authenticated as the agent principal, bound to that one repository,
66
+ * and refused everything except proposing. It lands as a candidate awaiting a
67
+ * named person's agreement, and nothing this command can do agrees to it.
68
+ */
69
+ export declare function runPropose(options: ProposeOptions): Promise<number>;
@@ -0,0 +1,284 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
3
+ import { proposalReviewLink } from "../client.js";
4
+ import { commandLine } from "../release.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import { StoreError, readCredentials } from "../store.js";
7
+ import {} from "../wire.js";
8
+ const MAX_FILE_BYTES = 64 * 1024;
9
+ /**
10
+ * Every field the server's schema requires with no default. It is every
11
+ * required key of `semanticMeaningSchema` and nothing else: three of these were
12
+ * missing once, and a file without them passed this gate, left the machine, and
13
+ * was refused on the other side, which is exactly what this check exists to
14
+ * prevent. `refactorExamples` is deliberately absent because the schema now
15
+ * accepts a meaning without one; a file that still carries one is still sent.
16
+ */
17
+ const REQUIRED_MEANING_FIELDS = [
18
+ "title",
19
+ "beneficiary",
20
+ "trigger",
21
+ "preconditions",
22
+ "observableOutcome",
23
+ "allowedVariations",
24
+ "nonGoals",
25
+ "passingExamples",
26
+ "failingExamples",
27
+ "scope",
28
+ ];
29
+ /** The only keys this command sends, at each of the two levels it reads. */
30
+ const ALLOWED_TOP_LEVEL = ["teachBack", "explicitIntent"];
31
+ const ALLOWED_TEACH_BACK = [
32
+ "meaning",
33
+ "unresolvedQuestions",
34
+ "proposedOwnerId",
35
+ "confidence",
36
+ "wrongOutcome",
37
+ "leastSure",
38
+ ];
39
+ function unexpectedKeys(value, allowed) {
40
+ return Object.keys(value).filter((key) => !allowed.includes(key));
41
+ }
42
+ /**
43
+ * The shape check that happens on the developer's machine, and the body it
44
+ * builds.
45
+ *
46
+ * `--file` reads whatever path it is handed, and an agent that mistypes one
47
+ * would otherwise upload a `.env`, a source file, or a log to a service whose
48
+ * whole boundary is that it never receives those. Refusing here means the wrong
49
+ * file never leaves the machine, rather than leaving it and being refused.
50
+ *
51
+ * So this returns the exact object to send rather than a verdict on the file.
52
+ * Spreading the parsed file into the request was the same mistake in a quieter
53
+ * form: whatever else the file happened to carry travelled with it, and the
54
+ * strict schema on the other side is a refusal after the fact, not a boundary.
55
+ */
56
+ export function readTeachBackFile(raw) {
57
+ if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
58
+ return { ok: false, reason: "the file is larger than 64 KiB" };
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return { ok: false, reason: "the file is not JSON" };
66
+ }
67
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
68
+ return { ok: false, reason: "the file is not a JSON object" };
69
+ }
70
+ const extra = unexpectedKeys(parsed, ALLOWED_TOP_LEVEL);
71
+ if (extra.length > 0) {
72
+ return {
73
+ ok: false,
74
+ reason: `it carries ${extra.join(", ")}, which a proposal does not have and this command will not send`,
75
+ };
76
+ }
77
+ const value = parsed;
78
+ const teachBack = value.teachBack;
79
+ if (teachBack === undefined || typeof teachBack !== "object" || Array.isArray(teachBack)) {
80
+ return { ok: false, reason: "it has no teachBack object" };
81
+ }
82
+ const extraTeachBack = unexpectedKeys(teachBack, ALLOWED_TEACH_BACK);
83
+ if (extraTeachBack.length > 0) {
84
+ return {
85
+ ok: false,
86
+ reason: `teachBack carries ${extraTeachBack.join(", ")}, which this command will not send`,
87
+ };
88
+ }
89
+ const meaning = teachBack.meaning;
90
+ if (meaning === undefined || typeof meaning !== "object" || Array.isArray(meaning)) {
91
+ return { ok: false, reason: "it has no teachBack.meaning" };
92
+ }
93
+ const missing = REQUIRED_MEANING_FIELDS.filter((field) => meaning[field] === undefined);
94
+ if (missing.length > 0) {
95
+ return { ok: false, reason: `teachBack.meaning is missing ${missing.join(", ")}` };
96
+ }
97
+ if (typeof value.explicitIntent !== "string" || value.explicitIntent.trim().length === 0) {
98
+ return { ok: false, reason: "it has no explicitIntent saying why this is being proposed" };
99
+ }
100
+ // Read from the file, never assumed. Every proposal this command ever filed
101
+ // was recorded at 1 because 1 was written here in the code, so the number an
102
+ // owner reads on the review page said nothing about this proposal: it said
103
+ // what the constant said. A file that will not state how sure its author was
104
+ // is refused rather than answered on their behalf.
105
+ const confidence = teachBack.confidence;
106
+ if (typeof confidence !== "number" || !Number.isFinite(confidence)) {
107
+ return {
108
+ ok: false,
109
+ reason: "teachBack.confidence is missing. Say how sure you are that this is the behavior the person meant, as a number from 0 to 1",
110
+ };
111
+ }
112
+ if (confidence < 0 || confidence > 1) {
113
+ return { ok: false, reason: "teachBack.confidence must be between 0 and 1" };
114
+ }
115
+ // The server refuses a proposal that cannot say what going wrong looks like,
116
+ // and it is right to: a behavior with no sentence of that shape has no failing
117
+ // case a check could ever catch. Refusing here as well means the person who
118
+ // wrote the file reads why on their own machine rather than after a round trip.
119
+ const wrongOutcome = teachBack.wrongOutcome;
120
+ if (typeof wrongOutcome !== "string" || wrongOutcome.trim().length === 0) {
121
+ return {
122
+ ok: false,
123
+ reason: 'teachBack.wrongOutcome is missing. Say what going wrong looks like, in the words the person used and as a must-not: "a second payment must not go out". If no sentence of that shape exists, nothing could ever fail this promise, so do not propose it',
124
+ };
125
+ }
126
+ if (teachBack.leastSure !== undefined && typeof teachBack.leastSure !== "string") {
127
+ return { ok: false, reason: "teachBack.leastSure must be one sentence of text" };
128
+ }
129
+ return {
130
+ ok: true,
131
+ value: {
132
+ explicitIntent: value.explicitIntent,
133
+ teachBack: {
134
+ meaning,
135
+ confidence,
136
+ wrongOutcome,
137
+ ...(teachBack.leastSure === undefined ? {} : { leastSure: teachBack.leastSure }),
138
+ ...(teachBack.unresolvedQuestions === undefined
139
+ ? {}
140
+ : { unresolvedQuestions: teachBack.unresolvedQuestions }),
141
+ ...(teachBack.proposedOwnerId === undefined
142
+ ? {}
143
+ : { proposedOwnerId: teachBack.proposedOwnerId }),
144
+ },
145
+ },
146
+ };
147
+ }
148
+ /**
149
+ * The repository a proposal is about, taken from the connection rather than from
150
+ * the file.
151
+ *
152
+ * A teach-back file names surfaces and labels; it has never named a repository
153
+ * id, because the setup route stamped one from the repository the request
154
+ * resolved. The connection is now what resolves it, so it stamps it: a file that
155
+ * carried some other repository's id cannot reach that repository, because this
156
+ * overwrites it and the server refuses a candidate whose scope does not match
157
+ * the principal's repository anyway.
158
+ */
159
+ function scopedMeaning(meaning, repositoryId) {
160
+ const scope = meaning.scope;
161
+ if (scope === null || typeof scope !== "object" || Array.isArray(scope))
162
+ return meaning;
163
+ return { ...meaning, scope: { ...scope, repositoryId } };
164
+ }
165
+ /**
166
+ * Proposes one promise over this repository's own agent connection.
167
+ *
168
+ * It used to go over the delegated setup session, which lives for a bounded time
169
+ * and is meant for setting Balladeer up. That made proposing something a person
170
+ * could only do for a while after pairing: the credential expired, and a command
171
+ * whose whole job is "propose this" answered "run setup again". The connection
172
+ * this uses instead is the one setup issued for this repository, the same one
173
+ * the agent host forwards, and it does not expire.
174
+ *
175
+ * Nothing widens by moving: the call is `propose_promise` on the MCP endpoint,
176
+ * so it is authenticated as the agent principal, bound to that one repository,
177
+ * and refused everything except proposing. It lands as a candidate awaiting a
178
+ * named person's agreement, and nothing this command can do agrees to it.
179
+ */
180
+ export async function runPropose(options) {
181
+ const emit = (step) => {
182
+ if (options.json)
183
+ options.write(`${JSON.stringify(step)}\n`);
184
+ };
185
+ const fail = (reason, message, exitCode) => {
186
+ if (options.json)
187
+ emit({ step: "error", reason, message, changed: false, exitCode });
188
+ else
189
+ options.write(`${message}\n`);
190
+ return exitCode;
191
+ };
192
+ if (!options.file) {
193
+ return fail("usage", "Give a proposal file: balladeer propose --file <path>.", 4);
194
+ }
195
+ let raw;
196
+ try {
197
+ raw = readFileSync(options.file, "utf8");
198
+ }
199
+ catch {
200
+ return fail("teachback_unreadable", `I could not read ${options.file}.`, 4);
201
+ }
202
+ const parsed = readTeachBackFile(raw);
203
+ if (!parsed.ok) {
204
+ return fail("teachback_malformed", `${options.file} is not a proposal: ${parsed.reason}. Nothing was sent.`, 4);
205
+ }
206
+ let credentials;
207
+ try {
208
+ credentials = readCredentials(options.environment);
209
+ }
210
+ catch (error) {
211
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
212
+ }
213
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
214
+ if (selection.kind === "refused") {
215
+ // The repository being connected somewhere else is not this machine holding
216
+ // a credential, and a proposal runs over the one this machine holds. Said in
217
+ // its own sentence, before anything is sent, because the remedy is to
218
+ // connect this machine rather than to pair again: a setup session, however
219
+ // fresh, cannot carry a proposal.
220
+ if (selection.missingFor !== undefined) {
221
+ return fail("no_agent_credential_on_this_machine", `${noAgentCredentialSentence(selection.missingFor)} Nothing was sent.`, 4);
222
+ }
223
+ return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was sent.`, 4);
224
+ }
225
+ const { agent } = selection;
226
+ const call = await callAgentTool(agent, "propose_promise", {
227
+ // A person or an agent ran this command against a file that says why. That
228
+ // is the explicit act the server requires; nothing here infers one.
229
+ explicitHumanAction: true,
230
+ source: "protect_behavior",
231
+ explicitIntent: parsed.value.explicitIntent,
232
+ meaning: scopedMeaning(parsed.value.teachBack.meaning, agent.repositoryId),
233
+ unresolvedQuestions: parsed.value.teachBack.unresolvedQuestions ?? [],
234
+ ...(parsed.value.teachBack.proposedOwnerId === undefined
235
+ ? {}
236
+ : { proposedOwnerId: parsed.value.teachBack.proposedOwnerId }),
237
+ confidence: parsed.value.teachBack.confidence,
238
+ wrongOutcome: parsed.value.teachBack.wrongOutcome,
239
+ ...(parsed.value.teachBack.leastSure === undefined
240
+ ? {}
241
+ : { leastSure: parsed.value.teachBack.leastSure }),
242
+ });
243
+ switch (call.kind) {
244
+ case "endpoint_refused":
245
+ return fail("agent_endpoint_unsafe", `${call.reason} Nothing was sent.`, 4);
246
+ case "unreachable":
247
+ return fail("control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}. Nothing was changed.`, 5);
248
+ case "client_too_old":
249
+ return fail("client_too_old", `This copy of the Balladeer command is too old for ${options.controlPlane}. Update it with: ${call.update}`, 3);
250
+ case "unauthorized":
251
+ return fail("agent_connection_revoked", `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. Run \`${commandLine(null, "setup")}\` here again, or issue a new connection at ${options.controlPlane}/setup. Nothing was recorded.`, 5);
252
+ case "tool_refusal":
253
+ return fail("propose_refused", `Balladeer refused this proposal: ${call.text}`, 5);
254
+ case "http":
255
+ return fail("propose_failed", `Balladeer answered ${call.status} to this proposal. Nothing was recorded.`, 5);
256
+ case "malformed":
257
+ return fail("propose_failed", "Balladeer could not record this proposal.", 5);
258
+ case "result":
259
+ break;
260
+ }
261
+ const candidateId = structuredString(call.structured, "id");
262
+ const status = structuredString(call.structured, "status");
263
+ if (candidateId === undefined) {
264
+ return fail("propose_failed", "Balladeer could not record this proposal.", 5);
265
+ }
266
+ if (status !== "pending_review") {
267
+ // The server matched something already on file rather than recording a new
268
+ // proposal, which is what the setup route used to answer as a conflict. It
269
+ // is reported as one here too: sending a person to agree to a record that
270
+ // was already decided is worse than saying nothing was recorded.
271
+ return fail("proposal_not_pending", `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`, 5);
272
+ }
273
+ // The review link is built from the address this copy paired with. The tool
274
+ // answers with an id and no link at all, so there is nothing here a
275
+ // misconfigured public base URL could redirect.
276
+ const review = proposalReviewLink(options.controlPlane, candidateId);
277
+ emit({ step: "promise", status: "proposed", candidateId, reviewUrl: review });
278
+ if (!options.json) {
279
+ options.write("Proposed. You will own it unless you named someone else; the owner reads it and clicks Agree, and nothing else can.\n");
280
+ options.write(`${review}\n`);
281
+ options.write(`Sent over ${agent.repository ?? "this repository"}'s Balladeer agent connection, which does not expire.\n`);
282
+ }
283
+ return 0;
284
+ }
@@ -0,0 +1,18 @@
1
+ export type RepositoriesOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ environment: NodeJS.ProcessEnv;
5
+ cwd: string;
6
+ write: (text: string) => void;
7
+ }>;
8
+ /**
9
+ * What this machine could add to Balladeer, and which of them are already in.
10
+ *
11
+ * It exists so the agent asking "which repositories shall I set up" has
12
+ * something to show. Setting one up is still `setup`, run in a checkout of it or
13
+ * named with `--repository`; this command changes nothing and sends nothing
14
+ * anywhere. The one network call it makes is to Balladeer, to find out which of
15
+ * these are already enrolled, and it is skipped entirely when no session is
16
+ * stored.
17
+ */
18
+ export declare function runRepositories(options: RepositoriesOptions): Promise<number>;