balladeer 1.0.0 → 1.0.2
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/README.md +53 -32
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +13 -1
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +181 -24
- package/dist/client.d.ts +24 -2
- package/dist/client.js +34 -3
- package/dist/commands/affected.js +8 -7
- package/dist/commands/check-seals.js +4 -4
- package/dist/commands/discover.js +16 -7
- package/dist/commands/explain.d.ts +1 -1
- package/dist/commands/explain.js +1 -1
- package/dist/commands/invite.js +2 -1
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +218 -0
- package/dist/commands/propose.d.ts +10 -0
- package/dist/commands/propose.js +29 -6
- package/dist/commands/repositories.js +1 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +131 -0
- package/dist/commands/setup.d.ts +29 -0
- package/dist/commands/setup.js +302 -92
- package/dist/commands/status.d.ts +16 -0
- package/dist/commands/status.js +106 -23
- package/dist/commands/touch-map.js +2 -2
- package/dist/commands/whoami.js +2 -1
- package/dist/conventions.d.ts +9 -1
- package/dist/conventions.js +9 -1
- package/dist/copy.d.ts +83 -7
- package/dist/copy.js +226 -29
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/git.d.ts +15 -0
- package/dist/git.js +23 -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/mcp-config.d.ts +10 -0
- package/dist/mcp-config.js +8 -4
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +11 -1
- package/dist/store.js +18 -6
- package/dist/wire.d.ts +95 -4
- package/dist/wire.js +2 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -34,7 +34,7 @@ export class RefusalError extends Error {
|
|
|
34
34
|
*
|
|
35
35
|
* Behind a platform proxy a server can resolve a link against the machine it is
|
|
36
36
|
* running on rather than against the address customers use, and the first
|
|
37
|
-
* production `propose` printed exactly that: `
|
|
37
|
+
* production `propose` printed exactly that: a review link on `localhost:8080`
|
|
38
38
|
* as the place to go and agree. A command that prints such a link is worse than
|
|
39
39
|
* one that prints none, because the person tries it.
|
|
40
40
|
*
|
|
@@ -42,8 +42,39 @@ export class RefusalError extends Error {
|
|
|
42
42
|
* identifier and this builds the address, which is why there is no parameter
|
|
43
43
|
* here for a server's own link to arrive through.
|
|
44
44
|
*/
|
|
45
|
-
export function reviewLink(controlPlane, path) {
|
|
46
|
-
|
|
45
|
+
export function reviewLink(controlPlane, path, workspaceId) {
|
|
46
|
+
const url = new URL(path, ensureTrailing(controlPlane));
|
|
47
|
+
if (workspaceId !== undefined)
|
|
48
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The one place this command decides where a person reads a proposal.
|
|
53
|
+
*
|
|
54
|
+
* Every review address this command prints, in a receipt, in a discovery run,
|
|
55
|
+
* or in its JSON, is built here, so the address is one edit rather than a hunt
|
|
56
|
+
* and a printed link cannot fall behind the page it names. It matches the
|
|
57
|
+
* server's own `proposal-links` helper: the page is `/proposals`, and
|
|
58
|
+
* the retired address redirects to it permanently, so a link an older copy of
|
|
59
|
+
* this command already printed still lands on it.
|
|
60
|
+
*/
|
|
61
|
+
export function proposalReviewLink(controlPlane, proposalId, workspaceId) {
|
|
62
|
+
return reviewLink(controlPlane, `proposals/${encodeURIComponent(proposalId)}`, workspaceId);
|
|
63
|
+
}
|
|
64
|
+
/** The address the whole waiting inbox is read on. */
|
|
65
|
+
export function proposalInboxLink(controlPlane) {
|
|
66
|
+
return reviewLink(controlPlane, "proposals");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One link that opens exactly the proposals named, and nothing else.
|
|
70
|
+
*
|
|
71
|
+
* The ids travel in the query string because the batch is exactly these
|
|
72
|
+
* promises: a link to the whole inbox would also open whatever was already
|
|
73
|
+
* waiting there, and a filter by repository would open a different set
|
|
74
|
+
* tomorrow.
|
|
75
|
+
*/
|
|
76
|
+
export function batchProposalReviewLink(controlPlane, proposalIds) {
|
|
77
|
+
return reviewLink(controlPlane, `proposals/review?ids=${proposalIds.join(",")}`);
|
|
47
78
|
}
|
|
48
79
|
function ensureTrailing(controlPlane) {
|
|
49
80
|
return controlPlane.endsWith("/") ? controlPlane : `${controlPlane}/`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { TOUCH_MAP_FILE, affectedPaths, readLocalPackages, readTouchMap, verifierDigest, } from "../touch-map.js";
|
|
3
|
-
import {} from "../
|
|
3
|
+
import { formatInstant } from "../local-time.js";
|
|
4
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
4
5
|
import { promiseRoot } from "./touch-map.js";
|
|
5
6
|
/** How many promises one path lists before the report says how many more. */
|
|
6
7
|
const NAMED_LIMIT = 20;
|
|
@@ -35,7 +36,7 @@ export async function runAffected(options) {
|
|
|
35
36
|
options.write(`${text}\n`);
|
|
36
37
|
};
|
|
37
38
|
if (options.paths.length === 0) {
|
|
38
|
-
const message =
|
|
39
|
+
const message = `Name at least one path: ${CLI_INVOCATION} affected src/orders/total.ts`;
|
|
39
40
|
if (options.json)
|
|
40
41
|
emit({ step: "error", reason: "usage", message, changed: false, exitCode: 4 });
|
|
41
42
|
else
|
|
@@ -54,8 +55,8 @@ export async function runAffected(options) {
|
|
|
54
55
|
const reading = readTouchMap(root);
|
|
55
56
|
if (reading.kind !== "map") {
|
|
56
57
|
const message = reading.kind === "absent"
|
|
57
|
-
? `There is no touch map in this checkout yet. Build one with:
|
|
58
|
-
: `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with:
|
|
58
|
+
? `There is no touch map in this checkout yet. Build one with: ${CLI_INVOCATION} touch-map`
|
|
59
|
+
: `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: ${CLI_INVOCATION} touch-map`;
|
|
59
60
|
emit({ step: "affected", mapped: false, paths: [], changed: false });
|
|
60
61
|
say(message);
|
|
61
62
|
// Zero, because having no map is not a fault in the change somebody is
|
|
@@ -86,7 +87,7 @@ export async function runAffected(options) {
|
|
|
86
87
|
changed: false,
|
|
87
88
|
});
|
|
88
89
|
if (touched.length === 0) {
|
|
89
|
-
say(`No promise in this map ran any of those files. The map was built ${reading.map.generatedAt}; a promise sealed since then is not in it.`);
|
|
90
|
+
say(`No promise in this map ran any of those files. The map was built ${formatInstant(reading.map.generatedAt)}; a promise sealed since then is not in it.`);
|
|
90
91
|
return 0;
|
|
91
92
|
}
|
|
92
93
|
for (const answer of answers) {
|
|
@@ -114,8 +115,8 @@ export async function runAffected(options) {
|
|
|
114
115
|
say("");
|
|
115
116
|
if (stale > 0) {
|
|
116
117
|
say(stale === 1
|
|
117
|
-
?
|
|
118
|
-
: `${stale} of those answers are stale. Run
|
|
118
|
+
? `One answer above is stale. Run ${CLI_INVOCATION} touch-map to measure it again.`
|
|
119
|
+
: `${stale} of those answers are stale. Run ${CLI_INVOCATION} touch-map to measure them again.`);
|
|
119
120
|
}
|
|
120
121
|
say("This came from your own checkout. Balladeer was not asked, and was told nothing.");
|
|
121
122
|
return 0;
|
|
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { runCommand } from "../gh.js";
|
|
4
4
|
import { repositoryRoot } from "../git.js";
|
|
5
5
|
import { promiseForPath, sealedPromises } from "../seals.js";
|
|
6
|
-
import {} from "../wire.js";
|
|
6
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
7
7
|
/**
|
|
8
8
|
* Where the pinned runner sits once setup's instructions have been followed.
|
|
9
9
|
*
|
|
@@ -258,17 +258,17 @@ function installPrePushHook(root, options, emit, say) {
|
|
|
258
258
|
const hookPath = join(root, ".git", "hooks", "pre-push");
|
|
259
259
|
if (existsSync(hookPath)) {
|
|
260
260
|
const message = `${hookPath} already exists, so nothing was written. Add this line to it yourself if you ` +
|
|
261
|
-
|
|
261
|
+
`want the check: ${CLI_INVOCATION} check-seals`;
|
|
262
262
|
emit({ step: "error", reason: "hook_exists", message, changed: false, exitCode: 4 });
|
|
263
263
|
say(message);
|
|
264
264
|
return 4;
|
|
265
265
|
}
|
|
266
266
|
const script = [
|
|
267
267
|
"#!/bin/sh",
|
|
268
|
-
|
|
268
|
+
`# Installed by: ${CLI_INVOCATION} check-seals --install-hook`,
|
|
269
269
|
"# It stops a push that would break a promise's seal, and says nothing otherwise.",
|
|
270
270
|
"# Delete this file to remove it.",
|
|
271
|
-
|
|
271
|
+
`${CLI_INVOCATION} check-seals || exit 1`,
|
|
272
272
|
"",
|
|
273
273
|
].join("\n");
|
|
274
274
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { callAgentTool, selectAgent, structuredString } from "../agent.js";
|
|
3
|
-
import {
|
|
2
|
+
import { callAgentTool, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
|
|
3
|
+
import { batchProposalReviewLink, proposalReviewLink } from "../client.js";
|
|
4
4
|
import { commandLine } from "../release.js";
|
|
5
5
|
import { repositoryHint } from "../repository.js";
|
|
6
6
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
7
|
-
import {} from "../wire.js";
|
|
7
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
import { readTeachBackFile } from "./propose.js";
|
|
9
9
|
/**
|
|
10
10
|
* Ten promises at 64 KiB each, which is the per-promise cap `propose` already
|
|
@@ -140,7 +140,7 @@ function titleOf(request) {
|
|
|
140
140
|
* different set tomorrow.
|
|
141
141
|
*/
|
|
142
142
|
export function batchReviewLink(controlPlane, candidateIds) {
|
|
143
|
-
return
|
|
143
|
+
return batchProposalReviewLink(controlPlane, candidateIds);
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
146
|
* Failures of the connection rather than of one promise, which is the
|
|
@@ -197,7 +197,7 @@ export async function runDiscover(options) {
|
|
|
197
197
|
return exitCode;
|
|
198
198
|
};
|
|
199
199
|
if (!options.file) {
|
|
200
|
-
return fail("usage",
|
|
200
|
+
return fail("usage", `Give a catalog file: ${CLI_INVOCATION} discover --file <path>.`, 4);
|
|
201
201
|
}
|
|
202
202
|
let raw;
|
|
203
203
|
try {
|
|
@@ -232,6 +232,7 @@ export async function runDiscover(options) {
|
|
|
232
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
233
|
}
|
|
234
234
|
const setup = await callAgentTool(agent, "get_promise_setup", {});
|
|
235
|
+
reportAgentEnforcementWarning(setup, options);
|
|
235
236
|
if (setup.kind !== "result") {
|
|
236
237
|
return fail("owner_unverified", `${memberListRefusal(setup.kind, options.controlPlane)} Nothing was sent.`, 5);
|
|
237
238
|
}
|
|
@@ -254,7 +255,7 @@ export async function runDiscover(options) {
|
|
|
254
255
|
index: index + 1,
|
|
255
256
|
title,
|
|
256
257
|
candidateId: outcome.candidateId,
|
|
257
|
-
reviewUrl:
|
|
258
|
+
reviewUrl: proposalReviewLink(options.controlPlane, outcome.candidateId, agent.workspaceId),
|
|
258
259
|
};
|
|
259
260
|
filed.push(entry);
|
|
260
261
|
emit({
|
|
@@ -336,6 +337,14 @@ async function proposeOne(agent, request, ownerId) {
|
|
|
336
337
|
// are not nine equally certain readings, and an owner deciding which to read
|
|
337
338
|
// first is entitled to see which one the agent was least sure of.
|
|
338
339
|
confidence: request.teachBack.confidence,
|
|
340
|
+
// The gate, on every entry in the file. A discovery run is where an
|
|
341
|
+
// unfailable promise is easiest to write: nine behaviors read off nine
|
|
342
|
+
// sources, and any one of them can be a sentence nothing could ever
|
|
343
|
+
// contradict. The file is refused entry by entry rather than in bulk.
|
|
344
|
+
wrongOutcome: request.teachBack.wrongOutcome,
|
|
345
|
+
...(request.teachBack.leastSure === undefined
|
|
346
|
+
? {}
|
|
347
|
+
: { leastSure: request.teachBack.leastSure }),
|
|
339
348
|
});
|
|
340
349
|
switch (call.kind) {
|
|
341
350
|
case "endpoint_refused":
|
|
@@ -387,7 +396,7 @@ async function proposeOne(agent, request, ownerId) {
|
|
|
387
396
|
if (status !== "pending_review") {
|
|
388
397
|
return {
|
|
389
398
|
ok: false,
|
|
390
|
-
reason: "
|
|
399
|
+
reason: "proposal_not_pending",
|
|
391
400
|
message: `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`,
|
|
392
401
|
};
|
|
393
402
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The boundary copy describes the product's boundary and is timeless; it names
|
|
6
6
|
* two credentials, a CI workflow, and a check on pull requests. In balladeer
|
|
7
|
-
* 1.0.
|
|
7
|
+
* 1.0.1 the command reaches none of those, so saying so is the difference
|
|
8
8
|
* between an explanation and a claim.
|
|
9
9
|
*/
|
|
10
10
|
export declare const RELEASE_PREFACE: string;
|
package/dist/commands/explain.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "../wire.js";
|
|
|
6
6
|
*
|
|
7
7
|
* The boundary copy describes the product's boundary and is timeless; it names
|
|
8
8
|
* two credentials, a CI workflow, and a check on pull requests. In balladeer
|
|
9
|
-
* 1.0.
|
|
9
|
+
* 1.0.1 the command reaches none of those, so saying so is the difference
|
|
10
10
|
* between an explanation and a claim.
|
|
11
11
|
*/
|
|
12
12
|
export const RELEASE_PREFACE = [
|
package/dist/commands/invite.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
2
|
+
import { formatInstant } from "../local-time.js";
|
|
2
3
|
import { commandLine } from "../release.js";
|
|
3
4
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
4
5
|
import {} from "../wire.js";
|
|
@@ -127,7 +128,7 @@ async function inviteOne(options, session, email) {
|
|
|
127
128
|
say(options, `Invited ${answer.email} to ${answer.workspaceName} as ${role}. Balladeer has sent them the email.`);
|
|
128
129
|
say(options, ` They can ${ROLE_MEANINGS[answer.requestedRole]}. They join when they accept, and not before.`);
|
|
129
130
|
if (answer.expiresAt !== null) {
|
|
130
|
-
say(options, ` The invitation stops working at ${answer.expiresAt}.`);
|
|
131
|
+
say(options, ` The invitation stops working at ${formatInstant(answer.expiresAt)}.`);
|
|
131
132
|
}
|
|
132
133
|
emit(options, {
|
|
133
134
|
step: "invitation",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type PrepareOptions = Readonly<{
|
|
2
|
+
controlPlane: string;
|
|
3
|
+
json: boolean;
|
|
4
|
+
promiseId: string;
|
|
5
|
+
/** Prepare a replacement, invalidating the packet nobody has spent. */
|
|
6
|
+
again: boolean;
|
|
7
|
+
/** The repository whose connection to use, named as `owner/name`. */
|
|
8
|
+
repo?: string;
|
|
9
|
+
/** The repository whose connection to use, named by id, as `mcp` takes it. */
|
|
10
|
+
repository?: string;
|
|
11
|
+
environment: NodeJS.ProcessEnv;
|
|
12
|
+
cwd: string;
|
|
13
|
+
write: (text: string) => void;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* A refusal Balladeer wrote, as this terminal has to say it.
|
|
17
|
+
*
|
|
18
|
+
* Two changes and no others. The instants move onto the reader's own clock,
|
|
19
|
+
* because the server writes UTC and "already prepared by Robert Clark on
|
|
20
|
+
* 2026-09-07T18:05:10.698Z" reached somebody whose clock said two in the
|
|
21
|
+
* afternoon. And the argument is named the way this command takes it. Every
|
|
22
|
+
* other word, including what to do next and why, is the server's.
|
|
23
|
+
*/
|
|
24
|
+
export declare function atThisTerminal(text: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* The six keys the sealed run reads, and nothing else.
|
|
27
|
+
*
|
|
28
|
+
* The runner rejects an unknown field outright, so a metadata file that carried
|
|
29
|
+
* a helpful comment, a timestamp, or the whole packet would fail the customer's
|
|
30
|
+
* protected run for a reason that had nothing to do with their behavior. This
|
|
31
|
+
* command therefore writes the packet's own `qualificationMetadata` object and
|
|
32
|
+
* refuses to write anything it does not recognise.
|
|
33
|
+
*/
|
|
34
|
+
declare const METADATA_KEYS: readonly ["schemaVersion", "workspaceLocator", "receiptId", "revisionId", "bindingId", "workflowDigest"];
|
|
35
|
+
type Metadata = Record<(typeof METADATA_KEYS)[number], string>;
|
|
36
|
+
export declare function readQualificationMetadata(structured: unknown): Readonly<{
|
|
37
|
+
ok: true;
|
|
38
|
+
value: Metadata;
|
|
39
|
+
}> | Readonly<{
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: string;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Where the file goes, refusing any path that would leave this repository.
|
|
45
|
+
*
|
|
46
|
+
* The path comes off the server's answer, and the whole point of this command is
|
|
47
|
+
* that it writes a file the person did not type. A server that answered with
|
|
48
|
+
* `../../etc/something` would otherwise have this command write there, so the
|
|
49
|
+
* resolved path has to stay under the directory the command was run in.
|
|
50
|
+
*/
|
|
51
|
+
export declare function metadataDestination(cwd: string, metadataPath: string): Readonly<{
|
|
52
|
+
ok: true;
|
|
53
|
+
path: string;
|
|
54
|
+
}> | Readonly<{
|
|
55
|
+
ok: false;
|
|
56
|
+
reason: string;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Prepares the one-time qualification setup for one promise, and writes the file
|
|
60
|
+
* where the sealed run reads it.
|
|
61
|
+
*
|
|
62
|
+
* It runs over this repository's own agent connection, which is what makes it
|
|
63
|
+
* usable at all: preparing a qualification setup is the agent's job, and the
|
|
64
|
+
* setup session that paired the machine expires. There is no sign-off here and
|
|
65
|
+
* no code to paste. Agreeing the meaning was the person's act; building the
|
|
66
|
+
* check that proves it is this command's caller's.
|
|
67
|
+
*
|
|
68
|
+
* The file is written once. A second run while nobody has published against the
|
|
69
|
+
* first packet is refused, naming who prepared it and when, and `--again` mints
|
|
70
|
+
* a replacement that invalidates the earlier one on the server as well as
|
|
71
|
+
* overwriting the file here.
|
|
72
|
+
*/
|
|
73
|
+
export declare function runPrepare(options: PrepareOptions): Promise<number>;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, 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
|
+
reportAgentEnforcementWarning(call, options);
|
|
154
|
+
switch (call.kind) {
|
|
155
|
+
case "endpoint_refused":
|
|
156
|
+
return fail("agent_endpoint_unsafe", `${call.reason} Nothing was prepared.`, 4);
|
|
157
|
+
case "unreachable":
|
|
158
|
+
return fail("control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}. Nothing was prepared.`, 5);
|
|
159
|
+
case "client_too_old":
|
|
160
|
+
return fail("client_too_old", `This copy of the Balladeer command is too old for ${options.controlPlane}. Update it with: ${call.update}`, 3);
|
|
161
|
+
case "unauthorized":
|
|
162
|
+
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);
|
|
163
|
+
case "tool_refusal":
|
|
164
|
+
// The server's own sentence, which names what to do next: use the packet
|
|
165
|
+
// that already exists, wait for the owner to agree, or run this again with
|
|
166
|
+
// --again. Rewriting it here would lose the part the person has to read,
|
|
167
|
+
// so `atThisTerminal` changes only the two things the server could not
|
|
168
|
+
// know: which clock the reader is on, and that they type a flag.
|
|
169
|
+
return fail("prepare_refused", `Balladeer refused this: ${atThisTerminal(call.text)}`, 5);
|
|
170
|
+
case "http":
|
|
171
|
+
return fail("prepare_failed", `Balladeer answered ${call.status} to this request. Nothing was prepared.`, 5);
|
|
172
|
+
case "malformed":
|
|
173
|
+
return fail("prepare_failed", "Balladeer could not prepare a qualification setup.", 5);
|
|
174
|
+
case "result":
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const structured = call.structured;
|
|
178
|
+
const metadataPath = typeof structured.metadataPath === "string" ? structured.metadataPath : "";
|
|
179
|
+
if (metadataPath === "") {
|
|
180
|
+
return fail("prepare_failed", "Balladeer could not prepare a qualification setup.", 5);
|
|
181
|
+
}
|
|
182
|
+
const destination = metadataDestination(options.cwd, metadataPath);
|
|
183
|
+
if (!destination.ok) {
|
|
184
|
+
// Minted on the server and not written here. Said plainly rather than
|
|
185
|
+
// silently: the packet is one-time, so somebody has to know it was spent.
|
|
186
|
+
return fail("metadata_path_unsafe", `Balladeer prepared the qualification setup, but ${destination.reason}, so nothing was written to disk. Nothing else was changed.`, 5);
|
|
187
|
+
}
|
|
188
|
+
const metadata = readQualificationMetadata(call.structured);
|
|
189
|
+
if (!metadata.ok) {
|
|
190
|
+
return fail("metadata_unusable", `Balladeer prepared the qualification setup, but ${metadata.reason}, so nothing was written to disk.`, 5);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
mkdirSync(dirname(destination.path), { recursive: true });
|
|
194
|
+
writeFileSync(destination.path, `${JSON.stringify(metadata.value, null, 2)}\n`, "utf8");
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return fail("metadata_unwritable", `Balladeer prepared the qualification setup, but ${metadataPath} could not be written: ${error instanceof Error ? error.message : String(error)}`, 4);
|
|
198
|
+
}
|
|
199
|
+
const superseded = typeof structured.supersededPackets === "number" ? structured.supersededPackets : 0;
|
|
200
|
+
emit({
|
|
201
|
+
step: "qualification",
|
|
202
|
+
status: "prepared",
|
|
203
|
+
promiseId: options.promiseId,
|
|
204
|
+
metadataPath,
|
|
205
|
+
supersededPackets: superseded,
|
|
206
|
+
});
|
|
207
|
+
if (!options.json) {
|
|
208
|
+
options.write(`${metadataPath}\n`);
|
|
209
|
+
if (superseded > 0) {
|
|
210
|
+
options.write(`The qualification setup prepared earlier is no longer valid: a run that publishes its identities is refused.\n`);
|
|
211
|
+
}
|
|
212
|
+
options.write("Next: build this promise's verifier, seal it, and push to the default branch.\n");
|
|
213
|
+
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");
|
|
214
|
+
for (const step of stringList(call.structured, "nextSteps"))
|
|
215
|
+
options.write(` ${step}\n`);
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
@@ -18,6 +18,16 @@ export type TeachBackRequest = Readonly<{
|
|
|
18
18
|
proposedOwnerId?: unknown;
|
|
19
19
|
/** How sure whoever wrote this file was, 0 to 1. Read, never assumed. */
|
|
20
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;
|
|
21
31
|
}>;
|
|
22
32
|
}>;
|
|
23
33
|
/**
|
package/dist/commands/propose.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
|
|
3
|
-
import {
|
|
2
|
+
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
|
|
3
|
+
import { proposalReviewLink } from "../client.js";
|
|
4
4
|
import { commandLine } from "../release.js";
|
|
5
5
|
import { repositoryHint } from "../repository.js";
|
|
6
6
|
import { StoreError, readCredentials } from "../store.js";
|
|
7
|
-
import {} from "../wire.js";
|
|
7
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
const MAX_FILE_BYTES = 64 * 1024;
|
|
9
9
|
/**
|
|
10
10
|
* Every field the server's schema requires with no default. It is every
|
|
@@ -33,6 +33,8 @@ const ALLOWED_TEACH_BACK = [
|
|
|
33
33
|
"unresolvedQuestions",
|
|
34
34
|
"proposedOwnerId",
|
|
35
35
|
"confidence",
|
|
36
|
+
"wrongOutcome",
|
|
37
|
+
"leastSure",
|
|
36
38
|
];
|
|
37
39
|
function unexpectedKeys(value, allowed) {
|
|
38
40
|
return Object.keys(value).filter((key) => !allowed.includes(key));
|
|
@@ -110,6 +112,20 @@ export function readTeachBackFile(raw) {
|
|
|
110
112
|
if (confidence < 0 || confidence > 1) {
|
|
111
113
|
return { ok: false, reason: "teachBack.confidence must be between 0 and 1" };
|
|
112
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
|
+
}
|
|
113
129
|
return {
|
|
114
130
|
ok: true,
|
|
115
131
|
value: {
|
|
@@ -117,6 +133,8 @@ export function readTeachBackFile(raw) {
|
|
|
117
133
|
teachBack: {
|
|
118
134
|
meaning,
|
|
119
135
|
confidence,
|
|
136
|
+
wrongOutcome,
|
|
137
|
+
...(teachBack.leastSure === undefined ? {} : { leastSure: teachBack.leastSure }),
|
|
120
138
|
...(teachBack.unresolvedQuestions === undefined
|
|
121
139
|
? {}
|
|
122
140
|
: { unresolvedQuestions: teachBack.unresolvedQuestions }),
|
|
@@ -172,7 +190,7 @@ export async function runPropose(options) {
|
|
|
172
190
|
return exitCode;
|
|
173
191
|
};
|
|
174
192
|
if (!options.file) {
|
|
175
|
-
return fail("usage",
|
|
193
|
+
return fail("usage", `Give a proposal file: ${CLI_INVOCATION} propose --file <path>.`, 4);
|
|
176
194
|
}
|
|
177
195
|
let raw;
|
|
178
196
|
try {
|
|
@@ -217,7 +235,12 @@ export async function runPropose(options) {
|
|
|
217
235
|
? {}
|
|
218
236
|
: { proposedOwnerId: parsed.value.teachBack.proposedOwnerId }),
|
|
219
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 }),
|
|
220
242
|
});
|
|
243
|
+
reportAgentEnforcementWarning(call, options);
|
|
221
244
|
switch (call.kind) {
|
|
222
245
|
case "endpoint_refused":
|
|
223
246
|
return fail("agent_endpoint_unsafe", `${call.reason} Nothing was sent.`, 4);
|
|
@@ -246,12 +269,12 @@ export async function runPropose(options) {
|
|
|
246
269
|
// proposal, which is what the setup route used to answer as a conflict. It
|
|
247
270
|
// is reported as one here too: sending a person to agree to a record that
|
|
248
271
|
// was already decided is worse than saying nothing was recorded.
|
|
249
|
-
return fail("
|
|
272
|
+
return fail("proposal_not_pending", `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`, 5);
|
|
250
273
|
}
|
|
251
274
|
// The review link is built from the address this copy paired with. The tool
|
|
252
275
|
// answers with an id and no link at all, so there is nothing here a
|
|
253
276
|
// misconfigured public base URL could redirect.
|
|
254
|
-
const review =
|
|
277
|
+
const review = proposalReviewLink(options.controlPlane, candidateId, agent.workspaceId);
|
|
255
278
|
emit({ step: "promise", status: "proposed", candidateId, reviewUrl: review });
|
|
256
279
|
if (!options.json) {
|
|
257
280
|
options.write("Proposed. You will own it unless you named someone else; the owner reads it and clicks Agree, and nothing else can.\n");
|
|
@@ -94,6 +94,7 @@ function report(options, rows, workspaceKnown) {
|
|
|
94
94
|
say(options, workspaceKnown
|
|
95
95
|
? "Repositories your GitHub account can see. Ones already in Balladeer are marked."
|
|
96
96
|
: "Repositories your GitHub account can see. Nothing is stored for this Balladeer, so which of them are already added is unknown.");
|
|
97
|
+
say(options, "These are GitHub repositories, not local directories or git worktrees.");
|
|
97
98
|
say(options, "");
|
|
98
99
|
for (const row of rows) {
|
|
99
100
|
const marks = [
|