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.
- package/LICENSE +200 -5
- package/README.md +167 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +531 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +142 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +123 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +403 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +198 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +217 -0
- package/dist/commands/propose.d.ts +69 -0
- package/dist/commands/propose.js +284 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +98 -0
- package/dist/commands/setup.js +1600 -0
- package/dist/commands/status.d.ts +51 -0
- package/dist/commands/status.js +542 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +80 -0
- package/dist/conventions.d.ts +77 -0
- package/dist/conventions.js +183 -0
- package/dist/copy.d.ts +224 -0
- package/dist/copy.js +641 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +91 -0
- package/dist/git.js +226 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +109 -0
- package/dist/mcp-config.js +234 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +108 -0
- package/dist/store.js +237 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +674 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- 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>;
|