balladeer 1.0.0 → 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/README.md CHANGED
@@ -19,11 +19,12 @@ The agent reads the setup instructions, runs the command, and hands you the two
19
19
  do. To run it yourself instead:
20
20
 
21
21
  ```sh
22
- npx balladeer@1.0.0 setup
22
+ npx -y balladeer@latest setup
23
23
  ```
24
24
 
25
- Pin the version, every time. Never use an unpinned or `@latest` form. The bare name resolves to an
26
- unrelated package that installs a different program.
25
+ Use `@latest` so a new session picks up the current command. Balladeer tells an older command when
26
+ an update is available. Run `balladeer setup --refresh` to update an existing repository connection
27
+ and its instructions without replacing other tools' settings.
27
28
 
28
29
  Run it inside the repository you want to protect. It prints the boundary explanation below, then one
29
30
  step at a time, and it exits within seconds rather than blocking on anything.
@@ -107,20 +108,22 @@ your own branch protection.
107
108
 
108
109
  ## Commands
109
110
 
110
- | Command | What it does |
111
- | ------------------------ | ------------------------------------------------------------------------------ |
112
- | `balladeer setup` | Run all five steps, then report what a person still has to do |
113
- | `balladeer repositories` | List what this machine could add, marking the one you are in and the ones in |
114
- | `balladeer invite` | Invite teammates by email, and say per address whether the email went |
115
- | `balladeer status` | Report this repository over its agent connection, and the workspace |
116
- | `balladeer status <id>` | Report one promise: whether it is holding, and if not, what the run reported |
117
- | `balladeer propose` | Propose one promise from a proposal file, over that connection |
118
- | `balladeer discover` | File a whole catalog, up to ten promises from one file, behind one review link |
119
- | `balladeer touch-map` | Record which files each promise's verifier runs, on this machine only |
120
- | `balladeer affected` | Say which promises the files you name touch, out of that record |
121
- | `balladeer mcp` | Forward one MCP session over stdio using this repository's connection |
122
- | `balladeer explain` | What Balladeer can and cannot see, where to watch it, and what leaving costs |
123
- | `balladeer whoami` | Report the stored session's workspace, role, scopes, and expiry |
111
+ | Command | What it does |
112
+ | ------------------------ | -------------------------------------------------------------------------------- |
113
+ | `balladeer setup` | Run all five steps, then report what a person still has to do |
114
+ | `balladeer repositories` | List what this machine could add, marking the one you are in and the ones in |
115
+ | `balladeer invite` | Invite teammates by email, and say per address whether the email went |
116
+ | `balladeer status` | Report this repository over its agent connection, and the workspace |
117
+ | `balladeer status <id>` | Report one promise: whether it is holding, and if not, what the run reported |
118
+ | `balladeer prepare <id>` | Prepare a promise's one-time qualification setup, and write it where CI reads it |
119
+ | `balladeer propose` | Propose one promise from a proposal file, over that connection |
120
+ | `balladeer discover` | File a whole catalog, up to ten promises from one file, behind one review link |
121
+ | `balladeer touch-map` | Record which files each promise's verifier runs, on this machine only |
122
+ | `balladeer affected` | Say which promises the files you name touch, out of that record |
123
+ | `balladeer session` | Print the id for this piece of work, and the line to write into the commit |
124
+ | `balladeer mcp` | Forward one MCP session over stdio using this repository's connection |
125
+ | `balladeer explain` | What Balladeer can and cannot see, where to watch it, and what leaving costs |
126
+ | `balladeer whoami` | Report the stored session's workspace, role, scopes, and expiry |
124
127
 
125
128
  `--json` emits one object per step on stdout and nothing else. `--wait` makes `setup` poll for the
126
129
  approval instead of exiting; without it the command exits and a later run finishes the pairing.
@@ -148,7 +151,17 @@ once they exist, and what stopping costs: your tests are yours, they keep runnin
148
151
  administrator can download everything Balladeer holds at any time.
149
152
 
150
153
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already claimed, 3 this
151
- copy is too old for the server, 4 usage or credential store problem, 5 transport or server failure.
154
+ copy is too old for the server, 4 usage or credential store problem, 5 transport or server failure,
155
+ 6 an earlier Balladeer is still installed on this machine and nothing was changed.
156
+
157
+ ## An earlier Balladeer on the same machine
158
+
159
+ A machine that ran the earlier Balladeer still carries it: that client registers an MCP server named
160
+ `balladeer` for the whole machine and installs a session hook of its own, and neither comes off when
161
+ this one goes on. Setup finds it before it writes anything and stops with one instruction. If you
162
+ used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it
163
+ prints, then continue. That uninstall is reversible, it backs up `~/.balladeer`, and it leaves the
164
+ old binary in place. `balladeer setup --force` runs anyway, for whoever has decided to keep both.
152
165
 
153
166
  ## Revoking
154
167
 
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,14 @@ type Parsed = Readonly<{
6
6
  wait: boolean;
7
7
  /** Repair the files a previous setup wrote, and do nothing else. */
8
8
  refresh: boolean;
9
+ /** `setup --force`: set up even though an earlier Balladeer is still installed. */
10
+ force: boolean;
11
+ /**
12
+ * `setup --claude-desktop` / `--no-claude-desktop`. Undefined is neither
13
+ * asked for nor refused, which is the ordinary run: connect the chat client
14
+ * where it is installed and say nothing where it is not.
15
+ */
16
+ claudeDesktop: boolean | undefined;
9
17
  repo: string | undefined;
10
18
  file: string | undefined;
11
19
  repository: string | undefined;
@@ -21,6 +29,12 @@ type Parsed = Readonly<{
21
29
  owner: string | undefined;
22
30
  /** `check-seals --install-hook`: write the optional pre-push hook. */
23
31
  installHook: boolean;
32
+ /** `session --new`: start a different session even though one is current. */
33
+ fresh: boolean;
34
+ /** `session --record`: read the trailer out of HEAD and record that commit. */
35
+ record: boolean;
36
+ /** `prepare --again`: mint a replacement packet and invalidate the unspent one. */
37
+ again: boolean;
24
38
  /** `check-seals --runner`: where the pinned runner is, when it has moved. */
25
39
  runner: string | undefined;
26
40
  createWorkspace: string | undefined;
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { runAffected } from "./commands/affected.js";
@@ -7,9 +8,11 @@ import { runDiscover } from "./commands/discover.js";
7
8
  import { runExplain } from "./commands/explain.js";
8
9
  import { parseRole, runInvite } from "./commands/invite.js";
9
10
  import { runMcp } from "./commands/mcp.js";
11
+ import { runPrepare } from "./commands/prepare.js";
10
12
  import { runPropose } from "./commands/propose.js";
11
13
  import { runRepositories } from "./commands/repositories.js";
12
14
  import { CREATE_WORKSPACE_MAX_LENGTH, runSetup } from "./commands/setup.js";
15
+ import { runSession } from "./commands/session.js";
13
16
  import { runStatus } from "./commands/status.js";
14
17
  import { runTouchMap } from "./commands/touch-map.js";
15
18
  import { runWhoami } from "./commands/whoami.js";
@@ -19,7 +22,8 @@ import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
19
22
  const USAGE = `balladeer ${CLI_VERSION}
20
23
 
21
24
  balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
22
- [--create-workspace <name>] [--refresh]
25
+ [--create-workspace <name>] [--refresh] [--force]
26
+ [--claude-desktop | --no-claude-desktop]
23
27
  Pair this session, add this repository, connect this coding agent and CI,
24
28
  and report what a person still has to do. Name --repository more than once
25
29
  to add other repositories to the same workspace; each of those is added and
@@ -28,10 +32,18 @@ const USAGE = `balladeer ${CLI_VERSION}
28
32
  signs in and creates the workspace themselves; this command never creates
29
33
  one.
30
34
  --refresh does one thing and talks to nobody: it rewrites this
31
- repository's balladeer entry in .mcp.json and its Balladeer instructions
32
- block to the current form, leaves every other entry in that file alone,
33
- and prints what it changed. Run it when Balladeer says a newer version is
34
- available.
35
+ repository's balladeer entry in .mcp.json, its Balladeer instructions
36
+ block and its Claude desktop chat entry to the current form, leaves every
37
+ other entry in those files alone, and prints what it changed. Run it when
38
+ Balladeer says a newer version is available.
39
+ --force sets up even though an earlier Balladeer is still installed on
40
+ this machine. Without it, a run that finds the older client's machine-wide
41
+ MCP entry or session hook stops and says to remove it first.
42
+ Claude desktop chat is connected too, under a key named for this
43
+ repository, wherever the app is installed. --claude-desktop asks for it by
44
+ name, so a machine with no Claude desktop on it says so instead of
45
+ skipping quietly; --no-claude-desktop leaves that file alone entirely.
46
+ Quit and reopen the app afterwards: it reads its configuration at startup.
35
47
 
36
48
  balladeer repositories [--json] [--control-plane <url>]
37
49
  List the repositories this machine's GitHub account can see, marking the
@@ -54,6 +66,19 @@ const USAGE = `balladeer ${CLI_VERSION}
54
66
  Propose one promise from a proposal file, over this repository's agent
55
67
  connection. A named person still agrees to it.
56
68
 
69
+ balladeer prepare <promise id> [--again] [--repo owner/name] [--repository <uuid>]
70
+ [--json] [--control-plane <url>]
71
+ Prepare the one-time qualification setup for a promise whose meaning is
72
+ agreed and which nothing is checking yet, and write it to
73
+ .continuity/qualification/<promise id>.json, which is where the sealed run
74
+ reads it. No sign-off and no code: agreeing the meaning was the person's
75
+ act, and building the check that proves it is yours. Then build the
76
+ verifier, seal it, and push to the default branch; protection starts by
77
+ itself when that run qualifies. It is one-time, so while nobody has
78
+ published against the packet already prepared this refuses and says who
79
+ prepared it and when. --again prepares a replacement and invalidates that
80
+ earlier packet: a run publishing its identities afterwards is refused.
81
+
57
82
  balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
58
83
  [--owner <membership id>] [--json]
59
84
  Propose a whole discovered catalog, up to ten promises from one file, each
@@ -79,6 +104,16 @@ const USAGE = `balladeer ${CLI_VERSION}
79
104
  answer whose verifier has changed since the map was built. It reads one
80
105
  local file and contacts nothing.
81
106
 
107
+ balladeer session [--new] [--record] [--repo owner/name] [--repository <uuid>] [--json]
108
+ [--control-plane <url>]
109
+ Print the id for this piece of work, and the one line to write into the
110
+ commit it produces. Pass the id to every promise read you make, so a check
111
+ that goes red later can be read back against what Balladeer told you
112
+ before you started. --new starts a different session; --record reads the
113
+ Balladeer-Session line out of the commit at HEAD and tells Balladeer which
114
+ commit this session wrote. Your commit message never leaves this machine:
115
+ only the session id and the commit SHA are sent.
116
+
82
117
  balladeer explain [--control-plane <url>]
83
118
  Print, word for word, what Balladeer can and cannot see, where to watch
84
119
  your promises, and what leaving costs.
@@ -95,7 +130,8 @@ const USAGE = `balladeer ${CLI_VERSION}
95
130
 
96
131
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
97
132
  claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
98
- 5 transport or server failure.
133
+ 5 transport or server failure, 6 an earlier Balladeer is still installed on this
134
+ machine and nothing was changed.
99
135
  `;
100
136
  const SUBCOMMANDS = { agent: ["rotate"] };
101
137
  export function parseArguments(argv) {
@@ -111,7 +147,12 @@ export function parseArguments(argv) {
111
147
  let json = false;
112
148
  let wait = false;
113
149
  let refresh = false;
150
+ let force = false;
151
+ let claudeDesktop;
114
152
  let installHook = false;
153
+ let fresh = false;
154
+ let record = false;
155
+ let again = false;
115
156
  let runner;
116
157
  let controlPlane;
117
158
  let repo;
@@ -147,14 +188,26 @@ export function parseArguments(argv) {
147
188
  const inline = rest.length > 0 ? rest.join("=") : undefined;
148
189
  if (name === "--json")
149
190
  json = true;
191
+ else if (name === "--again")
192
+ again = true;
150
193
  else if (name === "--install-hook")
151
194
  installHook = true;
195
+ else if (name === "--new")
196
+ fresh = true;
197
+ else if (name === "--record")
198
+ record = true;
152
199
  else if (name === "--runner")
153
200
  runner = value("--runner", inline);
154
201
  else if (name === "--wait")
155
202
  wait = true;
156
203
  else if (name === "--refresh")
157
204
  refresh = true;
205
+ else if (name === "--force")
206
+ force = true;
207
+ else if (name === "--claude-desktop")
208
+ claudeDesktop = true;
209
+ else if (name === "--no-claude-desktop")
210
+ claudeDesktop = false;
158
211
  else if (name === "--control-plane")
159
212
  controlPlane = value("--control-plane", inline);
160
213
  else if (name === "--repo")
@@ -181,8 +234,20 @@ export function parseArguments(argv) {
181
234
  // value that lost its flag, and it was refused before this loop learned to
182
235
  // collect them, so it is refused here rather than ignored.
183
236
  // `affected` is the other one, and the words it takes are paths in the change
184
- // in front of the person.
185
- if (positional.length > 0 && command !== "invite" && command !== "affected") {
237
+ // in front of the person. `prepare` and `status` each take exactly one bare
238
+ // word, the promise id a person copied off a promise page, which is the form
239
+ // both of those commands are told to people and to agents in.
240
+ // `--again` invalidates a packet somebody may be halfway through using, so it
241
+ // belongs to the one command that mints one. Accepted silently elsewhere, it
242
+ // would read as a general "do it anyway" flag.
243
+ if (again && command !== "prepare") {
244
+ throw new StoreError("usage", "--again belongs to prepare: run `balladeer prepare <promise id> --again`.");
245
+ }
246
+ if (positional.length > 0 &&
247
+ command !== "invite" &&
248
+ command !== "affected" &&
249
+ command !== "prepare" &&
250
+ command !== "status") {
186
251
  throw new StoreError("usage", `${command} takes no bare arguments: ${positional.join(", ")}.`);
187
252
  }
188
253
  // `--repository` names one repository everywhere but `setup`, where it names
@@ -195,6 +260,18 @@ export function parseArguments(argv) {
195
260
  if (refresh && command !== "setup") {
196
261
  throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
197
262
  }
263
+ // `--force` says one thing only: set up alongside an earlier Balladeer. Taken
264
+ // silently by another command it would read as a general "do it anyway" flag,
265
+ // which is exactly what nothing else here offers.
266
+ if (force && command !== "setup") {
267
+ throw new StoreError("usage", "--force belongs to setup: run `balladeer setup --force`.");
268
+ }
269
+ // Connecting a chat client is something setup does, so the flag belongs to
270
+ // setup. Accepted silently on `status`, it would let somebody believe they had
271
+ // connected Claude desktop when nothing had written a line.
272
+ if (claudeDesktop !== undefined && command !== "setup") {
273
+ throw new StoreError("usage", "--claude-desktop belongs to setup: run `balladeer setup --claude-desktop`.");
274
+ }
198
275
  if (command !== "setup" && repositories.length > 1) {
199
276
  throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
200
277
  }
@@ -205,12 +282,17 @@ export function parseArguments(argv) {
205
282
  json,
206
283
  wait,
207
284
  refresh,
285
+ force,
286
+ claudeDesktop,
208
287
  repo,
209
288
  file,
210
289
  repository: repositories[0],
211
290
  repositories,
212
291
  owner,
213
292
  installHook,
293
+ fresh,
294
+ record,
295
+ again,
214
296
  runner,
215
297
  createWorkspace,
216
298
  positional,
@@ -248,6 +330,8 @@ async function dispatch(parsed, write) {
248
330
  json: parsed.json,
249
331
  wait: parsed.wait,
250
332
  refresh: parsed.refresh,
333
+ force: parsed.force,
334
+ ...(parsed.claudeDesktop === undefined ? {} : { claudeDesktop: parsed.claudeDesktop }),
251
335
  ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
252
336
  repositories: parsed.repositories,
253
337
  ...(parsed.createWorkspace === undefined
@@ -296,6 +380,18 @@ async function dispatch(parsed, write) {
296
380
  cwd: process.cwd(),
297
381
  write,
298
382
  });
383
+ case "session":
384
+ return runSession({
385
+ controlPlane: parsed.controlPlane,
386
+ json: parsed.json,
387
+ record: parsed.record,
388
+ fresh: parsed.fresh,
389
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
390
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
391
+ environment: process.env,
392
+ cwd: process.cwd(),
393
+ write,
394
+ });
299
395
  case "affected":
300
396
  return runAffected({
301
397
  json: parsed.json,
@@ -322,6 +418,26 @@ async function dispatch(parsed, write) {
322
418
  write,
323
419
  });
324
420
  }
421
+ case "prepare": {
422
+ // One id, and one only. Preparing two packets from one line would mint
423
+ // two one-time identities and write two files, and reporting the first
424
+ // silently would leave the second promise unprepared with nobody told.
425
+ if (parsed.positional.length !== 1) {
426
+ process.stderr.write("prepare reads one promise id: balladeer prepare <promise id> [--again].\n");
427
+ return 4;
428
+ }
429
+ return runPrepare({
430
+ controlPlane: parsed.controlPlane,
431
+ json: parsed.json,
432
+ promiseId: parsed.positional[0],
433
+ again: parsed.again,
434
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
435
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
436
+ environment: process.env,
437
+ cwd: process.cwd(),
438
+ write,
439
+ });
440
+ }
325
441
  case "propose":
326
442
  return runPropose({
327
443
  controlPlane: parsed.controlPlane,
@@ -384,9 +500,32 @@ async function dispatch(parsed, write) {
384
500
  return 4;
385
501
  }
386
502
  }
503
+ /**
504
+ * The real file behind a path, following symlinks.
505
+ *
506
+ * `npx` and every global install run this program through a
507
+ * `node_modules/.bin/balladeer` symlink, so `process.argv[1]` is the link and
508
+ * not the file Node loaded. `resolve` does not follow a link, so comparing
509
+ * resolved paths made the guard below false for every customer: the process
510
+ * exited 0 having printed nothing, and only a checkout running
511
+ * `node dist/cli.js` ever saw the program run at all.
512
+ *
513
+ * A path that cannot be read back falls through to the resolved form rather
514
+ * than throwing, because a guard that decides whether the program runs must
515
+ * never be the thing that stops it.
516
+ */
517
+ function programPath(path) {
518
+ const absolute = resolve(path);
519
+ try {
520
+ return realpathSync(absolute);
521
+ }
522
+ catch {
523
+ return absolute;
524
+ }
525
+ }
387
526
  // Only when this file is the program, so a test may import `main` without the
388
527
  // import itself running a command.
389
528
  const entry = process.argv[1];
390
- if (entry !== undefined && fileURLToPath(import.meta.url) === resolve(entry)) {
529
+ if (entry !== undefined && programPath(fileURLToPath(import.meta.url)) === programPath(entry)) {
391
530
  process.exitCode = await main(process.argv.slice(2));
392
531
  }
package/dist/client.d.ts CHANGED
@@ -18,7 +18,7 @@ export declare class RefusalError extends Error {
18
18
  *
19
19
  * Behind a platform proxy a server can resolve a link against the machine it is
20
20
  * running on rather than against the address customers use, and the first
21
- * production `propose` printed exactly that: `https://localhost:8080/candidates/...`
21
+ * production `propose` printed exactly that: a review link on `localhost:8080`
22
22
  * as the place to go and agree. A command that prints such a link is worse than
23
23
  * one that prints none, because the person tries it.
24
24
  *
@@ -27,6 +27,28 @@ export declare class RefusalError extends Error {
27
27
  * here for a server's own link to arrive through.
28
28
  */
29
29
  export declare function reviewLink(controlPlane: string, path: string): string;
30
+ /**
31
+ * The one place this command decides where a person reads a proposal.
32
+ *
33
+ * Every review address this command prints, in a receipt, in a discovery run,
34
+ * or in its JSON, is built here, so the address is one edit rather than a hunt
35
+ * and a printed link cannot fall behind the page it names. It matches the
36
+ * server's own `proposal-links` helper: the page is `/proposals`, and
37
+ * the retired address redirects to it permanently, so a link an older copy of
38
+ * this command already printed still lands on it.
39
+ */
40
+ export declare function proposalReviewLink(controlPlane: string, proposalId: string): string;
41
+ /** The address the whole waiting inbox is read on. */
42
+ export declare function proposalInboxLink(controlPlane: string): string;
43
+ /**
44
+ * One link that opens exactly the proposals named, and nothing else.
45
+ *
46
+ * The ids travel in the query string because the batch is exactly these
47
+ * promises: a link to the whole inbox would also open whatever was already
48
+ * waiting there, and a filter by repository would open a different set
49
+ * tomorrow.
50
+ */
51
+ export declare function batchProposalReviewLink(controlPlane: string, proposalIds: readonly string[]): string;
30
52
  export type RequestOptions = Readonly<{
31
53
  method: "GET" | "POST";
32
54
  path: string;
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
  *
@@ -45,6 +45,34 @@ export class RefusalError extends Error {
45
45
  export function reviewLink(controlPlane, path) {
46
46
  return new URL(path, ensureTrailing(controlPlane)).toString();
47
47
  }
48
+ /**
49
+ * The one place this command decides where a person reads a proposal.
50
+ *
51
+ * Every review address this command prints, in a receipt, in a discovery run,
52
+ * or in its JSON, is built here, so the address is one edit rather than a hunt
53
+ * and a printed link cannot fall behind the page it names. It matches the
54
+ * server's own `proposal-links` helper: the page is `/proposals`, and
55
+ * the retired address redirects to it permanently, so a link an older copy of
56
+ * this command already printed still lands on it.
57
+ */
58
+ export function proposalReviewLink(controlPlane, proposalId) {
59
+ return reviewLink(controlPlane, `proposals/${encodeURIComponent(proposalId)}`);
60
+ }
61
+ /** The address the whole waiting inbox is read on. */
62
+ export function proposalInboxLink(controlPlane) {
63
+ return reviewLink(controlPlane, "proposals");
64
+ }
65
+ /**
66
+ * One link that opens exactly the proposals named, and nothing else.
67
+ *
68
+ * The ids travel in the query string because the batch is exactly these
69
+ * promises: a link to the whole inbox would also open whatever was already
70
+ * waiting there, and a filter by repository would open a different set
71
+ * tomorrow.
72
+ */
73
+ export function batchProposalReviewLink(controlPlane, proposalIds) {
74
+ return reviewLink(controlPlane, `proposals/review?ids=${proposalIds.join(",")}`);
75
+ }
48
76
  function ensureTrailing(controlPlane) {
49
77
  return controlPlane.endsWith("/") ? controlPlane : `${controlPlane}/`;
50
78
  }
@@ -1,5 +1,6 @@
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 { formatInstant } from "../local-time.js";
3
4
  import {} 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. */
@@ -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) {
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { callAgentTool, selectAgent, structuredString } from "../agent.js";
3
- import { reviewLink } from "../client.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";
@@ -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
@@ -254,7 +254,7 @@ export async function runDiscover(options) {
254
254
  index: index + 1,
255
255
  title,
256
256
  candidateId: outcome.candidateId,
257
- reviewUrl: reviewLink(options.controlPlane, `candidates/${outcome.candidateId}`),
257
+ reviewUrl: proposalReviewLink(options.controlPlane, outcome.candidateId),
258
258
  };
259
259
  filed.push(entry);
260
260
  emit({
@@ -336,6 +336,14 @@ async function proposeOne(agent, request, ownerId) {
336
336
  // are not nine equally certain readings, and an owner deciding which to read
337
337
  // first is entitled to see which one the agent was least sure of.
338
338
  confidence: request.teachBack.confidence,
339
+ // The gate, on every entry in the file. A discovery run is where an
340
+ // unfailable promise is easiest to write: nine behaviors read off nine
341
+ // sources, and any one of them can be a sentence nothing could ever
342
+ // contradict. The file is refused entry by entry rather than in bulk.
343
+ wrongOutcome: request.teachBack.wrongOutcome,
344
+ ...(request.teachBack.leastSure === undefined
345
+ ? {}
346
+ : { leastSure: request.teachBack.leastSure }),
339
347
  });
340
348
  switch (call.kind) {
341
349
  case "endpoint_refused":
@@ -387,7 +395,7 @@ async function proposeOne(agent, request, ownerId) {
387
395
  if (status !== "pending_review") {
388
396
  return {
389
397
  ok: false,
390
- reason: "candidate_not_pending",
398
+ reason: "proposal_not_pending",
391
399
  message: `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`,
392
400
  };
393
401
  }
@@ -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 {};