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.
Files changed (47) hide show
  1. package/README.md +53 -32
  2. package/dist/agent.d.ts +5 -0
  3. package/dist/agent.js +13 -1
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +181 -24
  6. package/dist/client.d.ts +24 -2
  7. package/dist/client.js +34 -3
  8. package/dist/commands/affected.js +8 -7
  9. package/dist/commands/check-seals.js +4 -4
  10. package/dist/commands/discover.js +16 -7
  11. package/dist/commands/explain.d.ts +1 -1
  12. package/dist/commands/explain.js +1 -1
  13. package/dist/commands/invite.js +2 -1
  14. package/dist/commands/prepare.d.ts +74 -0
  15. package/dist/commands/prepare.js +218 -0
  16. package/dist/commands/propose.d.ts +10 -0
  17. package/dist/commands/propose.js +29 -6
  18. package/dist/commands/repositories.js +1 -0
  19. package/dist/commands/session.d.ts +35 -0
  20. package/dist/commands/session.js +131 -0
  21. package/dist/commands/setup.d.ts +29 -0
  22. package/dist/commands/setup.js +302 -92
  23. package/dist/commands/status.d.ts +16 -0
  24. package/dist/commands/status.js +106 -23
  25. package/dist/commands/touch-map.js +2 -2
  26. package/dist/commands/whoami.js +2 -1
  27. package/dist/conventions.d.ts +9 -1
  28. package/dist/conventions.js +9 -1
  29. package/dist/copy.d.ts +83 -7
  30. package/dist/copy.js +226 -29
  31. package/dist/desktop-config.d.ts +85 -0
  32. package/dist/desktop-config.js +217 -0
  33. package/dist/git.d.ts +15 -0
  34. package/dist/git.js +23 -0
  35. package/dist/legacy.d.ts +41 -0
  36. package/dist/legacy.js +143 -0
  37. package/dist/local-time.d.ts +66 -0
  38. package/dist/local-time.js +84 -0
  39. package/dist/mcp-config.d.ts +10 -0
  40. package/dist/mcp-config.js +8 -4
  41. package/dist/session.d.ts +84 -0
  42. package/dist/session.js +135 -0
  43. package/dist/store.d.ts +11 -1
  44. package/dist/store.js +18 -6
  45. package/dist/wire.d.ts +95 -4
  46. package/dist/wire.js +2 -1
  47. 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: `https://localhost:8080/candidates/...`
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
- return new URL(path, ensureTrailing(controlPlane)).toString();
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 "../wire.js";
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 = "Name at least one path: balladeer affected src/orders/total.ts";
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: balladeer touch-map`
58
- : `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: balladeer touch-map`;
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
- ? "One answer above is stale. Run balladeer touch-map to measure it again."
118
- : `${stale} of those answers are stale. Run balladeer touch-map to measure them again.`);
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
- "want the check: balladeer check-seals";
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
- "# Installed by: balladeer check-seals --install-hook",
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
- "balladeer check-seals || exit 1",
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 { reviewLink } from "../client.js";
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 reviewLink(controlPlane, `candidates/review?ids=${candidateIds.join(",")}`);
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", "Give a catalog file: balladeer discover --file <path>.", 4);
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: reviewLink(options.controlPlane, `candidates/${outcome.candidateId}`),
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: "candidate_not_pending",
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.0 the command reaches none of those, so saying so is the difference
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;
@@ -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.0 the command reaches none of those, so saying so is the difference
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 = [
@@ -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
  /**
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
3
- import { reviewLink } from "../client.js";
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", "Give a proposal file: balladeer propose --file <path>.", 4);
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("candidate_not_pending", `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`, 5);
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 = reviewLink(options.controlPlane, `candidates/${candidateId}`);
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 = [