balladeer 1.0.2 → 1.0.4

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
@@ -24,7 +24,8 @@ npx -y balladeer@latest setup
24
24
 
25
25
  Use `@latest` so a new session picks up the current command. Balladeer tells an older command when
26
26
  an update is available. Run `npx -y balladeer@latest setup --refresh` to update an existing
27
- repository connection and its instructions without replacing other tools' settings.
27
+ repository connection and its instructions without replacing other tools' settings. For Codex, keep
28
+ `--client codex` on the refresh command: `npx -y balladeer@latest setup --client codex --refresh`.
28
29
 
29
30
  Run it inside the repository you want to protect. It prints the boundary explanation below, then one
30
31
  step at a time, and it exits within seconds rather than blocking on anything.
@@ -40,10 +41,19 @@ already enrolled it connects this machine and writes its MCP configuration; when
40
41
  leaves the checkout unchanged and Balladeer shows that enforcement tracking is unavailable until an
41
42
  administrator connects a repository.
42
43
 
44
+ ## If setup cannot save its local connection
45
+
46
+ Setup needs access to its private credential folder, normally `~/.config/balladeer`. If your
47
+ terminal or coding agent blocks that access, allow the command to access the private folder named in
48
+ the error, then repeat the same setup command with the same flags. Keep your existing saved
49
+ credentials; do not delete the store or move it into your repository. Setup checks local write
50
+ access before requesting a new browser pairing and reports a credential-store error separately from
51
+ a connection failure.
52
+
43
53
  ## Requirements
44
54
 
45
- - Node 22 or newer. The command has no runtime dependencies at all: it uses Node builtins and
46
- nothing else, so installing it pulls nothing else down.
55
+ - Node 22 or newer. The command uses Node builtins and the pinned `@iarna/toml` parser to preserve
56
+ Codex project configuration safely.
47
57
  - `git`, and the GitHub CLI (`gh`) signed in to an account with push access to the repository. Your
48
58
  `gh` is how the command reads the repository's ids and opens the pull request that carries the
49
59
  workflow. Balladeer itself never holds a GitHub credential.
@@ -100,10 +110,10 @@ with your checkout and no Balladeer credential.
100
110
  It writes, locally and visibly: your credentials to `$XDG_CONFIG_HOME/balladeer/credentials.json`,
101
111
  or `~/.config/balladeer/credentials.json`, with mode 600 inside a directory with mode 700, and it
102
112
  refuses to write them anywhere a commit could pick them up; a `balladeer` entry in the repository's
103
- `.mcp.json`, added beside whatever is already there and never over the top of somebody else's entry;
104
- and a marker-fenced block in the repository's `CLAUDE.md` or `AGENTS.md`, replaced between the
105
- markers on each run and never outside them. A file whose markers are damaged is left alone and
106
- reported rather than appended to.
113
+ `.mcp.json` for Claude, or `.codex/config.toml` with `--client codex`, preserving unrelated entries;
114
+ and a marker-fenced block in `AGENTS.md` for Codex (the existing `CLAUDE.md` or `AGENTS.md` for
115
+ Claude), replaced between the markers on each run and never outside them. A file whose markers are
116
+ damaged is left alone and reported rather than appended to.
107
117
 
108
118
  It changes, on your GitHub repository and through your own `gh`: the repository variables the
109
119
  workflow reads, so the workflow file itself names no host by hand.
@@ -175,10 +185,35 @@ keep both.
175
185
 
176
186
  Both credentials are yours to end. Revoke the setup session or the coding agent's connection at any
177
187
  time in Balladeer; the setup session also ends on its own. Removing the `balladeer` entry from
178
- `.mcp.json` disconnects the agent locally, and deleting the credential store leaves this machine
179
- with nothing of yours on it.
188
+ `.mcp.json` (Claude) or the managed table in `.codex/config.toml` (Codex) disconnects that project
189
+ host locally after its next reload. Archiving the credential store removes this machine's saved
190
+ Balladeer access without changing the server's records.
180
191
 
181
192
  ## Licence
182
193
 
183
194
  Apache License 2.0. The full text ships in this package as `LICENSE`, so you can read the rights you
184
195
  have from the copy on your own machine rather than taking a field in a manifest on trust.
196
+
197
+ ## Choose Codex or reconnect a workspace
198
+
199
+ Run `npx -y balladeer@latest setup --client codex` in the repository to write its
200
+ `.codex/config.toml` and the managed Balladeer block in `AGENTS.md`. This leaves Claude files,
201
+ Claude Desktop, global Codex settings and unrelated MCP entries alone. The default client remains
202
+ Claude; choose it explicitly with `--client claude`.
203
+
204
+ Open and trust the project in Codex if appropriate, then start a fresh session. Project settings are
205
+ ignored in untrusted projects; the setup connection probe does not prove the host loaded its tools.
206
+ Check the actual Balladeer tools in that new session. Refresh only local Codex configuration with
207
+ `setup --client codex --refresh`; no pairing or network request is made.
208
+
209
+ To reconnect a different existing workspace, run:
210
+
211
+ ```sh
212
+ npx -y balladeer@latest setup --client codex --existing --choose-workspace --wait
213
+ ```
214
+
215
+ Choose the workspace and approve pairing in the browser. The current control plane's setup session
216
+ is replaced only after a new pairing starts; all stored agent connections and other control planes
217
+ remain. `--create-workspace "Team name"` likewise opens fresh pairing instead of silently reusing
218
+ the previous workspace. It pre-fills the name; a human still creates the workspace. An interrupted
219
+ command can resume the same pending choice by running it again with the same flags.
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,8 @@ 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
+ client: "codex" | "claude" | undefined;
10
+ chooseWorkspace: boolean;
9
11
  /** `setup --force`: set up even though an earlier Balladeer is still installed. */
10
12
  force: boolean;
11
13
  /** `setup --existing`: connect only when this checkout is already enrolled. */
package/dist/cli.js CHANGED
@@ -22,13 +22,24 @@ import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
22
22
  const USAGE = `balladeer ${CLI_VERSION}
23
23
 
24
24
  ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
25
- [--create-workspace <name>] [--refresh] [--force]
25
+ [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
26
+ [--client codex|claude]
26
27
  [--existing]
27
28
  [--claude-desktop | --no-claude-desktop]
28
29
  Pair this session, add this repository, connect this coding agent and CI,
29
30
  and report what a person still has to do. Name --repository more than once
30
31
  to add other repositories to the same workspace; each of those is added and
31
32
  nothing more, because connecting an agent and CI happens in a checkout.
33
+ --client codex writes project .codex/config.toml and AGENTS.md.
34
+ Trust the project in Codex and start a fresh session to load its tools.
35
+ --client claude (the default) preserves the Claude setup behavior.
36
+ For --refresh only, a lone managed Codex entry selects Codex automatically.
37
+ If both clients have Balladeer entries, choose --client explicitly.
38
+ --choose-workspace starts fresh browser pairing for this control plane,
39
+ preserving other saved agent connections. Use it with --existing to
40
+ reconnect an existing workspace. --create-workspace also starts fresh
41
+ pairing instead of reusing the previously selected workspace.
42
+
32
43
  --create-workspace carries a name to the approval page, where a person
33
44
  signs in and creates the workspace themselves; this command never creates
34
45
  one.
@@ -36,8 +47,8 @@ const USAGE = `balladeer ${CLI_VERSION}
36
47
  If this repository is already connected, connect this machine; otherwise
37
48
  continue in the browser while an administrator connects it.
38
49
  --refresh does one thing and talks to nobody: it rewrites this
39
- repository's balladeer entry in .mcp.json, its Balladeer instructions
40
- block and its Claude desktop chat entry to the current form, leaves every
50
+ selected client's project MCP entry and Balladeer instructions block
51
+ (and Claude desktop entry for Claude) to the current form, leaves every
41
52
  other entry in those files alone, and prints what it changed. Run it when
42
53
  Balladeer says a newer version is available.
43
54
  --force sets up even though an earlier Balladeer is still installed on
@@ -49,6 +60,10 @@ const USAGE = `balladeer ${CLI_VERSION}
49
60
  skipping quietly; --no-claude-desktop leaves that file alone entirely.
50
61
  Quit and reopen the app afterwards: it reads its configuration at startup.
51
62
 
63
+ If setup cannot access its private credential folder, allow this command
64
+ to access the folder named in the error, then repeat the same setup command.
65
+ Keep existing saved credentials; do not delete the store to retry.
66
+
52
67
  ${CLI_INVOCATION} repositories [--json] [--control-plane <url>]
53
68
  List the repositories this machine's GitHub account can see, marking the
54
69
  one you are standing in and the ones Balladeer already has.
@@ -151,6 +166,8 @@ export function parseArguments(argv) {
151
166
  let json = false;
152
167
  let wait = false;
153
168
  let refresh = false;
169
+ let client;
170
+ let chooseWorkspace = false;
154
171
  let force = false;
155
172
  let existingOnly = false;
156
173
  let claudeDesktop;
@@ -175,6 +192,7 @@ export function parseArguments(argv) {
175
192
  "--owner": "a membership id",
176
193
  "--create-workspace": "a workspace name",
177
194
  "--role": "contributor, viewer, or administrator",
195
+ "--client": "codex or claude",
178
196
  "--runner": "a path to the pinned runner's cli.js",
179
197
  };
180
198
  const value = (flag, inline) => {
@@ -207,6 +225,14 @@ export function parseArguments(argv) {
207
225
  wait = true;
208
226
  else if (name === "--refresh")
209
227
  refresh = true;
228
+ else if (name === "--choose-workspace")
229
+ chooseWorkspace = true;
230
+ else if (name === "--client") {
231
+ const requested = value("--client", inline);
232
+ if (requested !== "codex" && requested !== "claude")
233
+ throw new StoreError("usage", "--client needs codex or claude.");
234
+ client = requested;
235
+ }
210
236
  else if (name === "--force")
211
237
  force = true;
212
238
  else if (name === "--existing")
@@ -264,6 +290,16 @@ export function parseArguments(argv) {
264
290
  // `--refresh` repairs the files setup writes, so it belongs to setup and to
265
291
  // nothing else. Accepting it silently elsewhere would let somebody run
266
292
  // `status --refresh`, see no error, and believe their install was repaired.
293
+ if ((client !== undefined || chooseWorkspace || createWorkspace !== undefined) &&
294
+ command !== "setup") {
295
+ throw new StoreError("usage", "--client, --choose-workspace and --create-workspace belong to setup.");
296
+ }
297
+ if (refresh && (chooseWorkspace || createWorkspace !== undefined)) {
298
+ throw new StoreError("usage", "--refresh only repairs local files; omit it to choose or create a workspace.");
299
+ }
300
+ if (client === "codex" && claudeDesktop === true) {
301
+ throw new StoreError("usage", "--client codex leaves Claude configuration alone; run a separate --client claude setup to connect Claude Desktop.");
302
+ }
267
303
  if (refresh && command !== "setup") {
268
304
  throw new StoreError("usage", `--refresh belongs to setup: run \`${CLI_INVOCATION} setup --refresh\`.`);
269
305
  }
@@ -298,6 +334,8 @@ export function parseArguments(argv) {
298
334
  json,
299
335
  wait,
300
336
  refresh,
337
+ client,
338
+ chooseWorkspace,
301
339
  force,
302
340
  existingOnly,
303
341
  claudeDesktop,
@@ -347,6 +385,8 @@ async function dispatch(parsed, write) {
347
385
  json: parsed.json,
348
386
  wait: parsed.wait,
349
387
  refresh: parsed.refresh,
388
+ chooseWorkspace: parsed.chooseWorkspace,
389
+ ...(parsed.client === undefined ? {} : { client: parsed.client }),
350
390
  force: parsed.force,
351
391
  existingOnly: parsed.existingOnly,
352
392
  ...(parsed.claudeDesktop === undefined ? {} : { claudeDesktop: parsed.claudeDesktop }),
@@ -0,0 +1,7 @@
1
+ import { type MergeResult, type StdioMcpEntry } from "./mcp-config.js";
2
+ export declare const CODEX_CONFIG_FILE = ".codex/config.toml";
3
+ export declare function readCodexEntry(root: string): unknown;
4
+ /** A client hint only when the complete file and our exact fenced table agree. */
5
+ export declare function hasManagedCodexEntry(root: string, controlPlane: string): boolean;
6
+ /** Only our fenced table is replaced; every unrelated byte and parsed setting survives. */
7
+ export declare function mergeCodexConfig(root: string, entry: StdioMcpEntry, controlPlane: string): MergeResult;
@@ -0,0 +1,159 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse } from "@iarna/toml";
5
+ import { isOurEntry } from "./mcp-config.js";
6
+ export const CODEX_CONFIG_FILE = ".codex/config.toml";
7
+ const START = "# balladeer:mcp:start";
8
+ const END = "# balladeer:mcp:end";
9
+ function render(entry) {
10
+ return [
11
+ START,
12
+ "[mcp_servers.balladeer]",
13
+ `command = ${JSON.stringify(entry.command)}`,
14
+ `args = ${JSON.stringify(entry.args)}`,
15
+ ...(entry.env
16
+ ? [
17
+ "[mcp_servers.balladeer.env]",
18
+ ...Object.entries(entry.env).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`),
19
+ ]
20
+ : []),
21
+ END,
22
+ "",
23
+ ].join("\n");
24
+ }
25
+ function withoutOurs(value) {
26
+ const copy = structuredClone(value);
27
+ const servers = copy.mcp_servers;
28
+ if (servers &&
29
+ typeof servers === "object" &&
30
+ !Array.isArray(servers) &&
31
+ !(servers instanceof Date)) {
32
+ delete servers.balladeer;
33
+ if (Object.keys(servers).length === 0)
34
+ delete copy.mcp_servers;
35
+ }
36
+ return JSON.stringify(copy);
37
+ }
38
+ export function readCodexEntry(root) {
39
+ try {
40
+ const config = parse(readFileSync(join(root, CODEX_CONFIG_FILE), "utf8"));
41
+ const servers = config.mcp_servers;
42
+ return servers &&
43
+ typeof servers === "object" &&
44
+ !Array.isArray(servers) &&
45
+ !(servers instanceof Date)
46
+ ? servers.balladeer
47
+ : undefined;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ /** A client hint only when the complete file and our exact fenced table agree. */
54
+ export function hasManagedCodexEntry(root, controlPlane) {
55
+ try {
56
+ const text = readFileSync(join(root, CODEX_CONFIG_FILE), "utf8");
57
+ const starts = [...text.matchAll(/^# balladeer:mcp:start\r?$/gm)];
58
+ const ends = [...text.matchAll(/^# balladeer:mcp:end\r?$/gm)];
59
+ if (starts.length !== 1 ||
60
+ ends.length !== 1 ||
61
+ !starts[0] ||
62
+ !ends[0] ||
63
+ starts[0].index >= ends[0].index)
64
+ return false;
65
+ const block = parse(text.slice(starts[0].index + starts[0][0].length, ends[0].index));
66
+ const servers = block.mcp_servers;
67
+ if (Object.keys(block).length !== 1 ||
68
+ !servers ||
69
+ typeof servers !== "object" ||
70
+ Array.isArray(servers) ||
71
+ servers instanceof Date ||
72
+ Object.keys(servers).length !== 1)
73
+ return false;
74
+ const own = readCodexEntry(root);
75
+ return (isOurEntry(own, controlPlane) && JSON.stringify(own) === JSON.stringify(servers.balladeer));
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ /** Only our fenced table is replaced; every unrelated byte and parsed setting survives. */
82
+ export function mergeCodexConfig(root, entry, controlPlane) {
83
+ const block = render(entry);
84
+ const refused = (reason) => ({
85
+ kind: "refused",
86
+ reason: `${CODEX_CONFIG_FILE}: ${reason} No configuration was changed.`,
87
+ block,
88
+ });
89
+ const directory = join(root, ".codex");
90
+ const path = join(root, CODEX_CONFIG_FILE);
91
+ if (existsSync(directory) &&
92
+ (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()))
93
+ return refused("the directory is not an ordinary project directory.");
94
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()))
95
+ return refused("the file is not an ordinary project file.");
96
+ let previous = "";
97
+ let parsed;
98
+ try {
99
+ if (existsSync(path))
100
+ previous = readFileSync(path, "utf8");
101
+ parsed = parse(previous);
102
+ }
103
+ catch {
104
+ return refused("could not read valid TOML; repair the file before running setup again.");
105
+ }
106
+ const starts = [...previous.matchAll(/^# balladeer:mcp:start\r?$/gm)];
107
+ const ends = [...previous.matchAll(/^# balladeer:mcp:end\r?$/gm)];
108
+ if (starts.length !== ends.length ||
109
+ starts.length > 1 ||
110
+ (starts[0] && ends[0] && starts[0].index >= ends[0].index))
111
+ return refused("the Balladeer markers are damaged; repair them before running setup again.");
112
+ const servers = parsed.mcp_servers;
113
+ if (servers !== undefined &&
114
+ (servers === null ||
115
+ typeof servers !== "object" ||
116
+ Array.isArray(servers) ||
117
+ servers instanceof Date))
118
+ return refused("mcp_servers must be a table.");
119
+ const own = servers && typeof servers === "object" && !Array.isArray(servers) && !(servers instanceof Date)
120
+ ? servers.balladeer
121
+ : undefined;
122
+ if (own !== undefined && (!starts[0] || !isOurEntry(own, controlPlane)))
123
+ return refused("an unmanaged or unrecognized balladeer table already exists. Review it and merge the supplied block yourself; setup will not replace it.");
124
+ if (starts[0] && own === undefined)
125
+ return refused("the marked block does not contain a Balladeer server; review the markers first.");
126
+ const next = starts[0] && ends[0]
127
+ ? previous.slice(0, starts[0].index) +
128
+ block.trimEnd() +
129
+ previous.slice(ends[0].index + ends[0][0].length)
130
+ : previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + "\n" + block;
131
+ try {
132
+ const result = parse(next);
133
+ if (withoutOurs(parsed) !== withoutOurs(result))
134
+ return refused("the marked replacement would change another setting; review the block manually.");
135
+ }
136
+ catch {
137
+ return refused("the proposed block conflicts with existing TOML tables; merge the supplied block manually.");
138
+ }
139
+ if (next === previous)
140
+ return { kind: "written", changed: false };
141
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
142
+ const temporary = join(directory, `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
143
+ let descriptor;
144
+ try {
145
+ descriptor = openSync(temporary, "wx", existsSync(path) ? lstatSync(path).mode & 0o777 : 0o600);
146
+ writeSync(descriptor, next);
147
+ fsyncSync(descriptor);
148
+ closeSync(descriptor);
149
+ descriptor = undefined;
150
+ renameSync(temporary, path);
151
+ }
152
+ finally {
153
+ if (descriptor !== undefined)
154
+ closeSync(descriptor);
155
+ if (existsSync(temporary))
156
+ unlinkSync(temporary);
157
+ }
158
+ return { kind: "written", changed: true };
159
+ }
@@ -4,6 +4,8 @@ export type SetupOptions = Readonly<{
4
4
  wait: boolean;
5
5
  repo?: string;
6
6
  createWorkspace?: string;
7
+ client?: "codex" | "claude";
8
+ chooseWorkspace?: boolean;
7
9
  /**
8
10
  * Every repository this run was told to set up, in the order they were named.
9
11
  *
@@ -3,18 +3,19 @@ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
6
+ import { CODEX_CONFIG_FILE, hasManagedCodexEntry, mergeCodexConfig, readCodexEntry, } from "../codex-config.js";
6
7
  import { writeConventions } from "../conventions.js";
7
8
  import { APPROVE_IN_THE_RIGHT_WORKSPACE, DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
8
9
  import { ghLogin, readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
9
10
  import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBranch, } from "../git.js";
10
- import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
11
+ import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
11
12
  import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
12
13
  import { selectAgent } from "../agent.js";
13
14
  import { findLegacyInstall, legacyMessage } from "../legacy.js";
14
15
  import { formatInstant } from "../local-time.js";
15
16
  import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
16
17
  import { hostHint, repositoryHint } from "../repository.js";
17
- import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
18
+ import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
18
19
  import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
19
20
  import { explainText } from "./explain.js";
20
21
  import { probeAgentConnection } from "./mcp.js";
@@ -167,10 +168,11 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
167
168
  ...JOIN_OR_CREATE.split("\n").map((line) => (line.length === 0 ? "" : ` ${line}`)),
168
169
  ];
169
170
  }
170
- function storeSession(credentials, controlPlane, token, session) {
171
+ function storeSession(credentials, controlPlane, token, session, workspaceChoice) {
171
172
  return putSession(dropPendingPairing(credentials, controlPlane), {
172
173
  controlPlane,
173
174
  sessionId: session.sessionId,
175
+ ...(workspaceChoice?.startsWith("create:") ? { workspaceChoice } : {}),
174
176
  token,
175
177
  workspaceId: session.workspaceId,
176
178
  workspaceName: session.workspaceName,
@@ -267,6 +269,7 @@ export async function runSetup(options) {
267
269
  const { controlPlane } = options;
268
270
  const now = options.now ?? (() => new Date());
269
271
  say(options, `Balladeer setup, version ${CLI_VERSION}.\n`);
272
+ say(options, `Agent client: ${options.client === "codex" ? "Codex" : "Claude"}. Use --client codex or --client claude to choose.`);
270
273
  emit(options, { step: "explain", version: CLI_VERSION });
271
274
  say(options, explainText(controlPlane));
272
275
  const chosen = chooseRepositories(namedRepositories(options), options.cwd);
@@ -288,8 +291,29 @@ export async function runSetup(options) {
288
291
  catch (error) {
289
292
  return storeFailure(options, error);
290
293
  }
294
+ // A completed create intent resumes only its own authenticated session. The
295
+ // name is local intent, never a lookup or grant of workspace authority. An
296
+ // explicit chooser always starts a switch. Keep the old session until the
297
+ // new pairing is claimed, so a failed start or claim loses no credentials.
298
+ const workspaceChoice = options.createWorkspace !== undefined
299
+ ? `create:${carriedName}`
300
+ : options.chooseWorkspace === true
301
+ ? "choose"
302
+ : undefined;
303
+ const previousSession = findSession(credentials, controlPlane);
304
+ const freshChoice = workspaceChoice !== undefined &&
305
+ (options.chooseWorkspace === true || previousSession?.workspaceChoice !== workspaceChoice);
306
+ if (workspaceChoice !== undefined) {
307
+ credentials = {
308
+ ...credentials,
309
+ pendingPairings: credentials.pendingPairings.filter((entry) => entry.controlPlane !== controlPlane || entry.workspaceChoice === workspaceChoice),
310
+ };
311
+ }
312
+ if (freshChoice) {
313
+ say(options, "Choose the workspace in the new browser pairing. Existing agent connections remain saved.");
314
+ }
291
315
  // A still-valid session carries straight on to the steps that need it.
292
- const stored = findSession(credentials, controlPlane);
316
+ const stored = freshChoice ? undefined : previousSession;
293
317
  if (stored && Date.parse(stored.expiresAt) > now().getTime()) {
294
318
  try {
295
319
  const answer = await request(controlPlane, {
@@ -361,6 +385,9 @@ export async function runSetup(options) {
361
385
  }
362
386
  let started;
363
387
  try {
388
+ // An unsaved claim secret cannot be recovered by retrying the browser link.
389
+ // Check local access before creating an unapproved remote pairing.
390
+ assertStoreWritable(options.environment);
364
391
  const { verifier, digest } = newVerifier();
365
392
  started = await request(controlPlane, {
366
393
  method: "POST",
@@ -375,6 +402,7 @@ export async function runSetup(options) {
375
402
  verificationUri: started.verificationUriComplete,
376
403
  expiresAt: started.expiresAt,
377
404
  startedAt: now().toISOString(),
405
+ ...(workspaceChoice === undefined ? {} : { workspaceChoice }),
378
406
  };
379
407
  credentials = putPendingPairing(credentials, pending);
380
408
  writeCredentials(credentials, options.environment);
@@ -432,7 +460,7 @@ async function claimOnce(options, credentials, pending) {
432
460
  if (answer.status === "pending") {
433
461
  return { kind: "pending", pollIntervalSeconds: answer.pollIntervalSeconds };
434
462
  }
435
- const updated = storeSession(credentials, options.controlPlane, answer.token, answer.session);
463
+ const updated = storeSession(credentials, options.controlPlane, answer.token, answer.session, pending.workspaceChoice);
436
464
  writeCredentials(updated, options.environment);
437
465
  reportPaired(options, answer.session);
438
466
  const session = findSession(updated, options.controlPlane);
@@ -839,7 +867,7 @@ function blockedRepository(options, role, name, reason, next, publicFacts) {
839
867
  }
840
868
  const NO_DESKTOP = { lines: [], json: undefined };
841
869
  function connectClaudeDesktop(options, repositoryId, repositoryName) {
842
- if (options.claudeDesktop === false)
870
+ if (options.claudeDesktop === false || options.client === "codex")
843
871
  return NO_DESKTOP;
844
872
  // Asked for by name, or taken by default. The difference is only whether a
845
873
  // machine with no Claude desktop on it hears about it: a person who typed the
@@ -898,11 +926,28 @@ function connectClaudeDesktop(options, repositoryId, repositoryName) {
898
926
  },
899
927
  };
900
928
  }
901
- function hostNextSteps() {
929
+ function hostConfigFile(options) {
930
+ return options.client === "codex" ? CODEX_CONFIG_FILE : MCP_CONFIG_FILE;
931
+ }
932
+ function mergeHostConfig(options, root, repositoryId, published) {
933
+ const entry = stdioEntry(repositoryId, published);
934
+ return options.client === "codex"
935
+ ? mergeCodexConfig(root, { ...entry, args: [...entry.args, "--control-plane", options.controlPlane] }, options.controlPlane)
936
+ : mergeMcpConfig(root, entry, options.controlPlane);
937
+ }
938
+ function writeHostConventions(options, root) {
939
+ return writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
940
+ }
941
+ function hostNextSteps(options) {
942
+ if (options.client === "codex")
943
+ return [
944
+ " Codex project configuration is ready. Open this repository in Codex and trust the project if appropriate; Codex ignores project configuration in untrusted projects.",
945
+ " Start a fresh Codex session to load .codex/config.toml and AGENTS.md, then check that Balladeer's tools are available. The connection probe does not prove your Codex host loaded them.",
946
+ ];
902
947
  return [
903
- ` Your agent host reads ${MCP_CONFIG_FILE} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
948
+ ` Your agent host reads ${hostConfigFile(options)} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
904
949
  " Claude Code asks you to approve a project MCP server the first time it sees one, so approve balladeer when it asks. In a session already running, /mcp lists the servers and reconnects them.",
905
- " Another host reads its own configuration rather than this file: add the same entry there, and start it again.",
950
+ " For Codex, run setup --client codex --refresh to configure this project's Codex MCP and AGENTS.md, then review project trust and start a fresh Codex session.",
906
951
  ];
907
952
  }
908
953
  /**
@@ -942,11 +987,11 @@ async function configureAgentHost(options, repositoryId, name, published) {
942
987
  let merged;
943
988
  let conventions;
944
989
  if (root !== undefined) {
945
- merged = mergeMcpConfig(root, stdioEntry(repositoryId, published), options.controlPlane);
990
+ merged = mergeHostConfig(options, root, repositoryId, published);
946
991
  if (merged.kind === "written") {
947
992
  if (merged.changed)
948
- files.push(MCP_CONFIG_FILE);
949
- conventions = writeConventions(root);
993
+ files.push(hostConfigFile(options));
994
+ conventions = writeHostConventions(options, root);
950
995
  if (conventions.kind === "written" && conventions.changed)
951
996
  files.push(conventions.file);
952
997
  }
@@ -961,7 +1006,7 @@ async function configureAgentHost(options, repositoryId, name, published) {
961
1006
  }
962
1007
  function reportAgentHost(options, configured, published) {
963
1008
  if (configured.root === undefined) {
964
- say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
1009
+ say(options, ` This directory is not a git work tree, so I wrote no ${hostConfigFile(options)} and no instructions block.`);
965
1010
  }
966
1011
  else {
967
1012
  if (configured.merged?.kind === "refused") {
@@ -975,7 +1020,7 @@ function reportAgentHost(options, configured, published) {
975
1020
  say(options, ` Repair the markers in ${configured.conventions.file}, or delete the block between them, then run this command again.`);
976
1021
  }
977
1022
  if (configured.merged?.kind === "written") {
978
- say(options, ` The ${MCP_CONFIG_FILE} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
1023
+ say(options, ` The ${hostConfigFile(options)} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
979
1024
  if (published === null && !existsSync(checkoutEntryPath())) {
980
1025
  say(options, ` That entry runs ${checkoutEntryPath()}, which this checkout has not built yet. Run \`pnpm --filter balladeer build\` once so your agent host can start it.`);
981
1026
  }
@@ -983,11 +1028,11 @@ function reportAgentHost(options, configured, published) {
983
1028
  }
984
1029
  for (const line of configured.desktop.lines)
985
1030
  say(options, line);
986
- for (const line of hostNextSteps())
1031
+ for (const line of hostNextSteps(options))
987
1032
  say(options, line);
988
1033
  say(options, published === null
989
1034
  ? ` Setup does not install a global balladeer executable. This checkout's CLI runs as \`${commandLine(null, "<command>")}\` after it is built.`
990
- : ` Setup does not install a global balladeer executable. Run the CLI as \`${commandLine(published, "<command>")}\`; ${MCP_CONFIG_FILE} invokes that same package automatically.`);
1035
+ : ` Setup does not install a global balladeer executable. Run the CLI as \`${commandLine(published, "<command>")}\`; ${hostConfigFile(options)} invokes that same package automatically.`);
991
1036
  if (configured.files.length > 0) {
992
1037
  say(options, ` ${configured.files.join(" and ")} ${configured.files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${configured.files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
993
1038
  }
@@ -1090,7 +1135,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
1090
1135
  // sends are.
1091
1136
  const proof = await proveAgent(agent);
1092
1137
  const wrote = configured.merged?.kind === "written" && configured.conventions?.kind === "written"
1093
- ? `, ${MCP_CONFIG_FILE} and ${configured.conventions.file} updated`
1138
+ ? `, ${hostConfigFile(options)} and ${configured.conventions.file} updated`
1094
1139
  : "";
1095
1140
  say(options, proof.kind === "answered"
1096
1141
  ? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
@@ -1589,13 +1634,21 @@ export function refreshPublishedForm(existing) {
1589
1634
  * changed. Run twice it changes nothing the second time, which is what makes it
1590
1635
  * safe to put in a nag an agent will act on without asking.
1591
1636
  */
1592
- async function runRefresh(options) {
1637
+ async function runRefresh(input) {
1638
+ let options = input;
1593
1639
  say(options, `Balladeer setup --refresh, version ${CLI_VERSION}.\n`);
1594
1640
  const root = await repositoryRoot(options.cwd);
1595
1641
  if (root === undefined) {
1596
1642
  return failRefresh(options, "not_a_repository", "This directory is not a git work tree, so there is no repository configuration to refresh.");
1597
1643
  }
1598
- const existing = currentEntry(readMcpConfig(root));
1644
+ if (options.client === undefined && hasManagedCodexEntry(root, options.controlPlane)) {
1645
+ if (isOurEntry(currentEntry(readMcpConfig(root)), options.controlPlane)) {
1646
+ return failRefresh(options, "client_ambiguous", "Both Codex and Claude have Balladeer project entries. Run setup --client codex --refresh or setup --client claude --refresh. No files were changed.");
1647
+ }
1648
+ options = { ...options, client: "codex" };
1649
+ say(options, "Using Codex for refresh because this repository has only its managed Balladeer entry.");
1650
+ }
1651
+ const existing = options.client === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
1599
1652
  let stored;
1600
1653
  let storedName;
1601
1654
  try {
@@ -1616,9 +1669,9 @@ async function runRefresh(options) {
1616
1669
  // that never paired can still repair a checkout somebody else set up.
1617
1670
  const repositoryId = stored ?? entryRepositoryId(existing);
1618
1671
  if (repositoryId === undefined) {
1619
- return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${MCP_CONFIG_FILE} has no balladeer entry. Run \`${commandLine(null, "setup")}\` to connect it.`);
1672
+ return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${hostConfigFile(options)} has no balladeer entry. Run \`${commandLine(null, "setup")}\` to connect it.`);
1620
1673
  }
1621
- const merged = mergeMcpConfig(root, stdioEntry(repositoryId, refreshPublishedForm(existing)), options.controlPlane);
1674
+ const merged = mergeHostConfig(options, root, repositoryId, refreshPublishedForm(existing));
1622
1675
  if (merged.kind === "refused") {
1623
1676
  say(options, merged.reason);
1624
1677
  say(options, "Merge this block into it yourself:");
@@ -1626,7 +1679,7 @@ async function runRefresh(options) {
1626
1679
  say(options, ` ${line}`);
1627
1680
  return failRefresh(options, "mcp_config_refused", merged.reason);
1628
1681
  }
1629
- const conventions = writeConventions(root);
1682
+ const conventions = writeHostConventions(options, root);
1630
1683
  if (conventions.kind === "refused") {
1631
1684
  say(options, conventions.reason);
1632
1685
  return failRefresh(options, "conventions_refused", conventions.reason);
@@ -1639,12 +1692,12 @@ async function runRefresh(options) {
1639
1692
  const desktop = connectClaudeDesktop(options, repositoryId, storedName ?? repositoryHint(options.cwd));
1640
1693
  const files = [];
1641
1694
  if (merged.changed)
1642
- files.push(MCP_CONFIG_FILE);
1695
+ files.push(hostConfigFile(options));
1643
1696
  if (conventions.changed)
1644
1697
  files.push(conventions.file);
1645
1698
  say(options, merged.changed
1646
- ? `Rewrote the balladeer entry in ${MCP_CONFIG_FILE}. Every other entry in that file is untouched.`
1647
- : `${MCP_CONFIG_FILE} already names the current command; I left it alone.`);
1699
+ ? `Rewrote the balladeer entry in ${hostConfigFile(options)}. Every other entry in that file is untouched.`
1700
+ : `${hostConfigFile(options)} already names the current command; I left it alone.`);
1648
1701
  say(options, conventions.changed
1649
1702
  ? `Rewrote the Balladeer block in ${conventions.file} to conventions v${conventions.version}${conventions.previousVersion === undefined
1650
1703
  ? ""
@@ -1655,6 +1708,9 @@ async function runRefresh(options) {
1655
1708
  // never a change anybody commits: it lives in this person's home directory.
1656
1709
  for (const line of desktop.lines)
1657
1710
  say(options, line.replace(/^ {2}/, ""));
1711
+ if (options.client === "codex")
1712
+ for (const line of hostNextSteps(options))
1713
+ say(options, line);
1658
1714
  if (files.length === 0 && desktop.json?.status !== "connected") {
1659
1715
  say(options, "Nothing to change: this repository is already on the current form.");
1660
1716
  }
@@ -27,7 +27,7 @@ export declare const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
27
27
  * instructions marker instead. A bump here with no change to the block would
28
28
  * rewrite twelve repositories to say exactly what they already said.
29
29
  */
30
- export declare const CONVENTIONS_VERSION = 16;
30
+ export declare const CONVENTIONS_VERSION = 17;
31
31
  export declare function managedByLine(version: number): string;
32
32
  /**
33
33
  * Which version of the block a file already carries, or nothing when it carries
@@ -74,4 +74,5 @@ export type ConventionsResult = Readonly<{
74
74
  export declare function writeConventions(repositoryRoot: string, options?: Readonly<{
75
75
  version?: number;
76
76
  body?: string;
77
+ file?: "CLAUDE.md" | "AGENTS.md";
77
78
  }>): ConventionsResult;
@@ -31,7 +31,7 @@ export const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
31
31
  * instructions marker instead. A bump here with no change to the block would
32
32
  * rewrite twelve repositories to say exactly what they already said.
33
33
  */
34
- export const CONVENTIONS_VERSION = 16;
34
+ export const CONVENTIONS_VERSION = 17;
35
35
  /** Both spellings: the stable marker, and the versioned one version 1 wrote. */
36
36
  const START_MARKER = /<!-- balladeer:conventions:start(?: v(\d{1,4}))? -->/g;
37
37
  const END_MARKER = /<!-- balladeer:conventions:end -->/g;
@@ -114,7 +114,7 @@ function writeAtomically(path, contents) {
114
114
  export function writeConventions(repositoryRoot, options = {}) {
115
115
  const version = options.version ?? CONVENTIONS_VERSION;
116
116
  const existingName = CANDIDATE_FILES.find((name) => existsSync(join(repositoryRoot, name)));
117
- const name = existingName ?? "CLAUDE.md";
117
+ const name = options.file ?? existingName ?? "CLAUDE.md";
118
118
  const path = join(repositoryRoot, name);
119
119
  const block = fenced(version, options.body ?? CONVENTIONS_BLOCK);
120
120
  let existing = "";
package/dist/copy.d.ts CHANGED
@@ -115,7 +115,7 @@ export declare const CAPTURE_SEAM = "### When to say nothing, and what a yes is
115
115
  * initialize, and the page `/agent` serves. A packaging test holds them
116
116
  * together.
117
117
  */
118
- export declare const SETTLING_QUESTIONS = "### When somebody says \"tell me about\" one\n\nAn id is how a person points at something here, and every promise page and every proposal page\ncarries one. A promise id starts with prom_ and a proposal id starts with cand_: expand a promise\nwith get_promise and a proposal with get_proposal, and read neither of them out as a list of fields.\nSay in four or five sentences what it is for, who it is for, when it applies and what must then be\ntrue. Then say where it stands: a promise is agreed, and either protected or not yet checked by\nanything; a proposal is agreed by nobody and waiting on the person it names.\n\nA proposal that still carries open questions is not finished, and settling them is usually why\nsomebody asked. Offer to work through them, then take them one at a time in the order they come\nback. For each one, say what it decides in their words rather than in the question's; give your\nrecommendation and the reason you hold it, drawn from this repository and from the promise itself;\nand stop there. When they answer, record what they said with resolve_question, in their own words\nwhere they gave you any, and where their answer changes the promise, follow it with update_proposal,\nadd_case or remove_case and tell them what you changed. None of that agrees to anything: the named\nowner agrees, in their own browser or through a sign-off you carry, and the questions they answered\nstay on the proposal in their name.\n\nThe same rule governs every question you leave open in the first place. A question that names a gap\nand stops is a note, and nobody can answer a note. Say what answering it decides, in the words a\ncustomer would use, and carry your own best guess with the reason behind it, so that the shortest\ntrue answer is yes. For example: \"Decides: whether a worker that is running but reconciling nothing\ncounts as an outage this promise covers. Best guess: yes, because the promise is about somebody\nhearing before a customer does, and a wedged worker is invisible to every check this repository has.\nSay yes, or tell me otherwise.\" Balladeer refuses a question filed without both halves and says\nwhich one is missing.";
118
+ export declare const SETTLING_QUESTIONS = "### When somebody says \"tell me about\" one\n\nAn id is how a person points at something here, and every promise page and every proposal page\ncarries one. A promise id starts with prom_ and a proposal id starts with cand_: expand a promise\nwith get_promise and a proposal with get_proposal, and read neither of them out as a list of fields.\nSay in four or five sentences what it is for, who it is for, when it applies and what must then be\ntrue. Then say where it stands: a promise is agreed, and either protected or not yet checked by\nanything; a proposal is agreed by nobody and waiting on the person it names.\n\nClarify ambiguity that materially changes the behavior during capture, within the existing\nquestion budget; never invent an answer.\n\nOrdinary open questions record uncertainty; they do not prevent the named owner from agreeing to\nthe behavior as written. Agreement does not answer them or add an unstated guarantee. Separate\ncatalog-conflict questions can hold agreement until the owner rules on the stated conflict. Do not\ncall a proposal unready just because it has ordinary open questions. Offer to discuss them if\nuseful; if the person wants to, take them one at a time in the order they come back. For each ordinary question,\nsay what it decides in their words rather than in the question's; give your\nrecommendation and the reason you hold it, drawn from this repository and from the promise itself; and stop there. When\nthey answer, record what they said with resolve_question, in their own words where they gave you\nany, and where their answer changes the promise, follow it with update_proposal, add_case or\nremove_case and tell them what you changed. Never invent an answer or clear uncertainty because\nthey chose to agree. None of that agrees to anything: the named owner agrees, in their own browser\nor through a sign-off you carry, and the questions they answered stay on the proposal in their name.\n\nThe same rule governs every question you leave open in the first place. A question that names a gap\nand stops is a note, and nobody can answer a note. Say what answering it decides, in the words a\ncustomer would use, and carry your own best guess with the reason behind it, so that the shortest\ntrue answer is yes. For example: \"Decides: whether a worker that is running but reconciling nothing\ncounts as an outage this promise covers. Best guess: yes, because the promise is about somebody\nhearing before a customer does, and a wedged worker is invisible to every check this repository has.\nSay yes, or tell me otherwise.\" Balladeer refuses a question filed without both halves and says\nwhich one is missing.";
119
119
  /**
120
120
  * What a session may spend on reading, in three sentences.
121
121
  *
@@ -193,7 +193,7 @@ export declare const SESSION_STAMP = "Run `npx -y balladeer@latest session` when
193
193
  * block that drifts per run would rewrite a customer's committed file on every
194
194
  * setup and the diff would say nothing.
195
195
  */
196
- export declare const CONVENTIONS_BLOCK = "## Balladeer promises\n\nBefore planning work in this repository, read the promises this team has already approved through\nthe Balladeer MCP server. They are the behaviors a named person has agreed the software keeps, so\nyour plan has to hold them, not just read them.\n\n### Reading what is already agreed\n\nRetrieve by promise, and only when this session has a reason to. One repository here can hold a\nthousand agreed promises, and a plan built from whatever survived a truncated catalog read is worse\nthan a plan built from none of it, because nothing tells you which half went missing.\n\nWhen a person gives you a promise id, expand exactly that one with get_promise and stop there. Every\npromise page carries a control that copies its id, so an id is what a person hands you when they\nmean a particular promise. Ask for one rather than searching for what they meant.\n\nConsult list_promises in two situations and no others. The person asks what this repository has\npromised, in which case page the index they asked for. Or the change you are about to make touches\npaths that carry promises, in which case give those paths to list_promises: it answers with the\npromises whose scope overlaps them, closest first, and with the few that name no path and so cover\nthe whole repository. That answer is a selection rather than a page and does not continue with a\ncursor, so when the total beside it is larger than what you were handed, narrow the paths rather\nthan asking for more.\n\nWhen you do not yet know which paths you are about to touch, do not call the index at all. Work from\nids until you do, because a page you did not ask a question of is not about your change, and reading\none as though it were is how a plan quietly misses the promise it breaks.\n\nTo learn which paths those are, read `paths` on a list_promises answer you asked for\nwithout paths of your own. It is the set of\nrepository paths this repository's promises are scoped to, deduplicated and bounded, with\n`pathsTruncated` saying whether there were more than the answer carries. Compare the files you are\nabout to change against it. Nothing matching means there is nothing here to read, and saying so is a\nbetter answer than a page of promises about somewhere else.\n\nNever call get_promise_context at the start of a session. It answers the markers you give it, and\nbefore you know what you are changing there are no markers to give: what comes back is a slice of\nthe catalog chosen by nothing. Call it once the work is in front of you, with that work's markers.\n\nEvery one of these reads is bounded and none of them returns the whole catalog. Read the total\nbeside the rows and the sentence in `scope` that says what the total is a total of, and when the\ntotal is larger than what you were handed, page or narrow rather than planning as though you had\nseen everything.\n\nEach index row carries what it takes to rule that promise out without fetching it: the repository,\nthe paths it covers, its one-sentence claim, what protects it and why, and when anything last\nchecked it.\n\nPrefer the measurement over the markers. Where this repository has a touch map, `npx -y balladeer@latest affected\n<paths...>` answers which promises ran the files in front of you, out of what each verifier actually\nexecuted the last time `npx -y balladeer@latest touch-map` measured it. Both commands run on this machine and\nsend Balladeer nothing, so you may name any path in the change.\n\nAn answer marked stale was measured against a verifier that has changed since, so read it as the\nlast thing anybody measured rather than as fact, and offer to run `npx -y balladeer@latest touch-map` again.\nWhere there is no map, where it does not name your paths, or where it reports a promise it could not\nmeasure, fall back to the paths on the index rows. The map narrows which promises are worth\nfetching. It never widens what you may read: the catalog is still never enumerated, and the fetch\nbudget is unchanged.\n\nWhen a retrieval receipt asks why you read a row, the answer for one the map named is `touch`.\n\nFetch by id, and only for an id the person gave you or an index row whose paths match the change in\nfront of you. Never enumerate the catalog. Never chain one fetch into the next to see the whole of\nsomething: when the rows do not settle it, narrow the filter rather than expanding another promise.\n\n### When to say nothing, and what a yes is worth\n\nMost rules are said in passing, in the middle of something else. Somebody saying one has not asked\nyou to record anything, so propose nothing and start no interview. When you do ask, it is one line\nappended to the end of the reply you were already going to give, never a message of its own, never\nasked twice about the same rule, and nothing is proposed until they say yes. A no ends it, and that\nrule is not raised again for the rest of the conversation.\n\nSilence is per conversation rather than per message. Once a conversation is one of these, you ask\nnothing for the rest of it, however good the rule sounds:\n\n- A question, or working out how something already behaves. Nothing is filed and nothing is offered.\n- A refactor. Nobody predicts a promise from a rewrite. Offer the promises this area already carries\n that nothing is checking yet, once, and then wait. Ask nothing about new ones.\n- A change to wording alone. Wording somebody may change again tomorrow is not a rule.\n- An incident, while it is still being fixed. Ask nothing at all until the fix is merged or they say\n it is done, however good tonight's failing case would be.\n- Somebody still weighing options. A decision nobody has made yet is not a rule.\n- An exploration or spike. It ends in nothing or in a plan, and its sentences sound like rules and\n are not.\n- Reading somebody else's change, while you are still reading it. Nothing is offered until they\n give a verdict.\n\nThe words to ask in, the moment to offer, and the shape a proposal takes are not in this file. The\nBalladeer server sends them at the start of every session, and its copy is the current one: read\nwhat it sent this session rather than what this file remembers.\n\n### Two promises Balladeer cannot keep\n\nA promise about speed needs three things before it is a promise at all: a number, a percentile, and\nwhere it is measured. \"The quote page answers in under one second at p95, measured in production at\npeak load\" is one Balladeer can keep. \"The quote page has to be fast\" is not. Say this, and ask for\nthe part that is missing rather than filing it:\n\n\"I can keep that once it has a number, a percentile, and a place it is measured. Without those it is\na wish, not a promise.\"\n\nBalladeer refuses one without all three and names which of them is missing. When all three are\nthere, write the measurement method into the promise, and tell them plainly that it reads agreed and\nunprotected until a test that actually measures it exists.\n\nA promise about how the team works is not something the software does, so no check can ever catch\nit. \"We must provide a low-friction capture experience\" is one of these. File nothing and say:\n\n\"That is a promise about how we work, not something the software does that a check can fail.\nBalladeer only keeps promises a check can catch. If a customer would notice something when this\nslips, say that and I will keep that instead.\"\n\nThen take the customer-visible half if they give you one, and file that instead.\n\nRun `npx -y balladeer@latest session` when you start work here and pass the id it prints to every promise read\nyou make, as `session`. It is the same id for the whole of one piece of work, and it costs nothing:\na read without it is answered exactly the same way.\n\nIf local file permissions refuse that session write, no session stamp was saved. Your saved\nconnection is unchanged; this does not prove its current server authorization. Continue\nalready-authorized MCP reads, coding and explicitly requested capture without the optional\n`session` field. Do not invent an ID, add a session trailer, or record an unsaved stamp. Do not\nbroaden filesystem access or move credentials to retry this write. A missing credential or server\nauthorization refusal is different: follow that refusal. Capture still needs the person's request\nor accepted offer, and human meaning approval is unchanged.\n\nIf a session ID was saved, near the end of the work call `mark_promise_used` with the ids you actually cited\nor acted on. Naming none is a fine answer. It is the only way anybody finds out which rows were\nworth sending you, because reading a promise and then ignoring it is not the same as using it.\n\nIf a session ID was saved, write `Balladeer-Session: <the id>` into the commit message or pull-request body you produce,\nand run `npx -y balladeer@latest session --record` once the commit exists. That is what lets a check that goes red\nnext week be read back against what Balladeer told you before you started. Your commit message never\nleaves your machine: only the session id and the commit SHA are sent.\n\n### When somebody asks you to protect a behavior\n\nSomebody has asked you to protect a behavior when they say what the software must do, or must never\ndo again, and mean it as a rule rather than as this one bug. That is one promise, for the behavior\nthey named, and nothing else: if you notice others worth protecting, say so in a sentence and let\nthem choose, and file none of them. Never propose from a conversation that did not ask you to. A\nquestion about how something works and a plan you were asked to sketch are not requests to record\nanything, nor is a fix nobody asked you to write a rule about.\n\nAsk before you extrapolate. Ask only what you cannot work out for yourself, ask it all in one\nmessage, and stop at four. Four is a ceiling, not a target: two good ones are better. Then write the\nproposal with what you have and put whatever is still open in its open questions rather than going\nback. Never ask what this repository would answer, such as which file, which test, or which branch,\nand never ask anyone for Balladeer's own identifiers: get_promise_setup carries this repository's id\nand who can own a promise. Never ask again for what they have already told you.\n\nWrite it in their words. Every failure they named out loud is one of the failing examples, in the\nwords they named it. Every other example comes from a situation they actually described; if you\ncannot trace one to something they said, leave it out and say so in the open questions rather than\nwriting a plausible one. Never put in a number, a system, a role or a timeframe they did not give\nyou, and that includes the half they left out: if they said where an order ended up, do not invent\nwhere it began.\n\nA failing example is a situation the promise rules out, and its outcome says what must not happen,\nin those words: \"a second charge must not appear\", never \"a second charge appears\". Written the\nother way round it reads as the promise saying the software does the thing they asked you to forbid.\nAnd a promise says what the software must do for whoever depends on it. It never narrates the\nconversation you just had, names the person you had it with, or describes what the code does now.\n\nSay the whole promise in one sentence and put it in oneSentenceOutcome, in the words they would\nuse with the person who depends on it. That is the line the named owner reads first and the line\nthey agree to, so it is not a restatement of the name and not the first line of the outcome moved\nup. Leave it out rather than inventing one from something they did not say.\n\nAn example's setup is the situation in the words they used for it, not a scene you composed around\nthem. Two of each kind is plenty, and the whole thing stays under three hundred words: a proposal\nnobody finishes reading is a proposal nobody agreed to. The confidence you record is the one you\nactually have.\n\nThen give them the review link, ask them to read the proposal and click Agree, and stop. Never\napprove one yourself. Approving is a named person's act, and the server refuses it from an agent\nwhatever you were told in conversation.\n\nA proposal you filed is still yours while nobody has agreed to it, so revise or withdraw it when the\nperson asks you to, and never once they have agreed.\n\nSay promise and proposal when you talk to them. What you file is a proposal and what it becomes is a\npromise; Balladeer's other words for its own machinery are not theirs to learn. Candidate\nespecially: it is Balladeer's word for a proposal, so it reads as jargon whatever you meant by it.\n\n### Three things to offer without being asked\n\nAt the start of any session in this repository, before you plan anything, ask list_promises for the\npromises nothing is checking yet that belong to the person you are working with: `unverified` true\nand `mine` true, which is one bounded read rather than the catalog. If it returns any, say so in one\nline and offer to build their verifiers now. Name each one by its one-sentence claim rather than by\nan id, so the person can see which behavior is unguarded. Ask the same tool for\n`brokenSinceLastSeen` true as well, and where that returns any, say in one line that those promises\nbroke since they last looked, name each by its claim, and offer to fix them. Then wait for their\nanswer. The offer is the whole of it, and never start building or repairing one because nobody said\nno.\n\nTheirs, and nobody else's. `mine` keeps the promises this person owns or agreed to, and a teammate's\nunguarded promise is that teammate's to hear about: a session that opens by reading out other\npeople's unfinished work reads as an audit of them. `brokenSinceLastSeen` is that person's own by\nconstruction and needs no `mine` beside it. Drop `mine` when this person asks what the rest of the\nteam has promised, and say whose promises you are showing them when you do.\n\nWhen you have proposed promises, show them here as well as there. Put each proposal in the\nconversation in full: its one-sentence claim, who it is for, when it applies and what must then be\ntrue, the numbered cases that must keep working and the ones that must be caught, and every question\nyou left open. Then offer to finish the agreement from here, by asking for a sign-off, giving them\nthe page it returns and taking the one-time code that page shows them, so the browser is needed only\nfor signing. Give them the review link in the same message too, because some people would rather\nread it there and edit it before they agree.\nA promise whose meaning is agreed and which nothing is checking yet is one you can finish. When\nsomebody gives you a promise id, run `npx -y balladeer@latest status <promise id>`, or expand it with get_promise.\nIf it comes back agreed with nothing checking it, say so in one line and offer to prepare and build\nits verifier now. Only if they say yes: `prepare_qualification` mints the one-time setup, or\n`npx -y balladeer@latest prepare <promise id>` mints it and writes it where the sealed run reads it. You need no\nsign-off for that and there is no code to ask anybody for, because agreeing the meaning was their\nact and building the check that proves it is yours. Then build the verifier, seal it, push, and tell\nthem protection starts by itself when that run qualifies. The offer is the whole of it: never\nprepare one because nobody said no.\n\n### When somebody says \"tell me about\" one\n\nAn id is how a person points at something here, and every promise page and every proposal page\ncarries one. A promise id starts with prom_ and a proposal id starts with cand_: expand a promise\nwith get_promise and a proposal with get_proposal, and read neither of them out as a list of fields.\nSay in four or five sentences what it is for, who it is for, when it applies and what must then be\ntrue. Then say where it stands: a promise is agreed, and either protected or not yet checked by\nanything; a proposal is agreed by nobody and waiting on the person it names.\n\nA proposal that still carries open questions is not finished, and settling them is usually why\nsomebody asked. Offer to work through them, then take them one at a time in the order they come\nback. For each one, say what it decides in their words rather than in the question's; give your\nrecommendation and the reason you hold it, drawn from this repository and from the promise itself;\nand stop there. When they answer, record what they said with resolve_question, in their own words\nwhere they gave you any, and where their answer changes the promise, follow it with update_proposal,\nadd_case or remove_case and tell them what you changed. None of that agrees to anything: the named\nowner agrees, in their own browser or through a sign-off you carry, and the questions they answered\nstay on the proposal in their name.\n\nThe same rule governs every question you leave open in the first place. A question that names a gap\nand stops is a note, and nobody can answer a note. Say what answering it decides, in the words a\ncustomer would use, and carry your own best guess with the reason behind it, so that the shortest\ntrue answer is yes. For example: \"Decides: whether a worker that is running but reconciling nothing\ncounts as an outage this promise covers. Best guess: yes, because the promise is about somebody\nhearing before a customer does, and a wedged worker is invisible to every check this repository has.\nSay yes, or tell me otherwise.\" Balladeer refuses a question filed without both halves and says\nwhich one is missing.\n\n### Files that are sealed, and the one reason to edit one\n\nEvery file under .continuity/promises/ is sealed. The promise that owns that directory records the\nexact bytes of each file in it, so editing one, adding one there, renaming one or deleting one\nbreaks the seal. A promise whose seal is broken stops being checked, and it stays that way until a\nnamed person qualifies it again, which is their afternoon rather than your commit. Nothing in there\nis ordinary source: keep it out of refactors, formatting runs and dependency upgrades.\n\nFind out which directories are sealed before you plan an edit, not after. npx -y balladeer@latest status lists\nthem, and list_promises names a promise's sealed directory on its row once a verifier is bound to\nit. Then, before you push, run npx -y balladeer@latest check-seals. It prints nothing and exits zero when your\nchange touches no seal, and names the promise, its owner and its page when your change would break\none.\n\nThe one reason to edit a sealed file is to repair a verifier that can no longer run: something it\nimports moved, or the language it is written in changed under it. Never edit one to make a failing\ncheck pass. A check going red is the promise doing its job, and the repair for that belongs in the\nbehavior it protects. A repair is not finished until the promise is sealed again with the runner\nthis repository is pinned to. get_promise_setup carries that exact seal command, and\nnpx -y balladeer@latest check-seals prints it beside any promise it names.\n\n### The rest of a promise's life\nWhen a promise is obsolete, finished, deliberately off for a while, or owned by the wrong person,\npropose the change and hand them the promise page. Deciding is theirs.\n\nYou can finish one of those acts here, and only one way. Ask for a sign-off with\nrequest_owner_signoff, give them the page it returns, and ask for the one-time code that page shows\nthem. Then call the act's own tool with that code and their own words. Never call one on your own\ninitiative, never on a general approval of some earlier act, and never ask for a code you were not\ngiven: a refusal is the person's to resolve, not yours to retry. Balladeer records them as the\nperson who acted and you as the messenger.\n\nReport the promise's state exactly as Balladeer reported it: proposed, agreed, or protected, never\none in place of another.\n\n### Where your team watches this\n\nBalladeer is a web app as well as these tools, at the address setup printed. Its catalog lists every\npromise with who owns it and whether anything is checking it, and each promise has a page of its own\nshowing what this team agreed the software must do and then every run that has checked it since,\nnewest first, with the commit each one checked. Whenever there is a link to give, give the link\nrather than a summary of it: the page says what you would have said, and it stays true after this\nconversation has ended.\n\nBalladeer also has a Slack app, which a workspace administrator installs from workspace settings.\nOnce it is installed, whoever owns a promise gets a direct message when theirs goes live and when a\nrun on the default branch breaks it. Until somebody installs it, nothing is sent anywhere, so say it\nis available rather than saying they will be told.\n\n### Questions people ask\n\nAnswer these when they come up. Where you do not know, say so and point at the address setup\nprinted: a confident wrong answer about what a vendor can see is worse than no answer.\n\nWhat it does: it holds the behaviors this team has agreed the software must keep, and reports\nwhether each one is still being kept, from this repository's own tests running in its own CI.\n\nWhat it sees: the text of each promise somebody approves, this repository's numeric ids and the name\nof its default branch, and from CI the pass or fail outcome, the commit checked, and content hashes.\nNever the code, the tests, the fixtures, the logs, the prompts, or the transcripts.\n\nWhat stopping costs: nothing that matters to their tests. The verifier package, its fixtures and the\nworkflow file are theirs, in their repository, running in their CI, and disconnecting changes none\nof them. An administrator can download everything Balladeer holds at any time from workspace\nsettings, and disconnecting hands them that same download in the response that ends access.\n\nWho can approve one: the named person who owns it, in their own browser. Not an administrator on\ntheir behalf, and never you.\n\nWhether it blocks a merge: no. The check is advisory on Balladeer's side, and their own branch\nprotection is what decides whether a failing check stops anything.\n\nWho can invite people and change setup: a workspace administrator. A contributor can read the\nworkspace and propose promises, and a viewer can read it. If somebody asks you to add a teammate,\ninvite_teammate returns the page an administrator sends the invitation from, with the address filled\nin for them to read. The tool sends nothing itself: an administrator presses Send.";
196
+ export declare const CONVENTIONS_BLOCK = "## Balladeer promises\n\nBefore planning work in this repository, read the promises this team has already approved through\nthe Balladeer MCP server. They are the behaviors a named person has agreed the software keeps, so\nyour plan has to hold them, not just read them.\n\n### Reading what is already agreed\n\nRetrieve by promise, and only when this session has a reason to. One repository here can hold a\nthousand agreed promises, and a plan built from whatever survived a truncated catalog read is worse\nthan a plan built from none of it, because nothing tells you which half went missing.\n\nWhen a person gives you a promise id, expand exactly that one with get_promise and stop there. Every\npromise page carries a control that copies its id, so an id is what a person hands you when they\nmean a particular promise. Ask for one rather than searching for what they meant.\n\nConsult list_promises in two situations and no others. The person asks what this repository has\npromised, in which case page the index they asked for. Or the change you are about to make touches\npaths that carry promises, in which case give those paths to list_promises: it answers with the\npromises whose scope overlaps them, closest first, and with the few that name no path and so cover\nthe whole repository. That answer is a selection rather than a page and does not continue with a\ncursor, so when the total beside it is larger than what you were handed, narrow the paths rather\nthan asking for more.\n\nWhen you do not yet know which paths you are about to touch, do not call the index at all. Work from\nids until you do, because a page you did not ask a question of is not about your change, and reading\none as though it were is how a plan quietly misses the promise it breaks.\n\nTo learn which paths those are, read `paths` on a list_promises answer you asked for\nwithout paths of your own. It is the set of\nrepository paths this repository's promises are scoped to, deduplicated and bounded, with\n`pathsTruncated` saying whether there were more than the answer carries. Compare the files you are\nabout to change against it. Nothing matching means there is nothing here to read, and saying so is a\nbetter answer than a page of promises about somewhere else.\n\nNever call get_promise_context at the start of a session. It answers the markers you give it, and\nbefore you know what you are changing there are no markers to give: what comes back is a slice of\nthe catalog chosen by nothing. Call it once the work is in front of you, with that work's markers.\n\nEvery one of these reads is bounded and none of them returns the whole catalog. Read the total\nbeside the rows and the sentence in `scope` that says what the total is a total of, and when the\ntotal is larger than what you were handed, page or narrow rather than planning as though you had\nseen everything.\n\nEach index row carries what it takes to rule that promise out without fetching it: the repository,\nthe paths it covers, its one-sentence claim, what protects it and why, and when anything last\nchecked it.\n\nPrefer the measurement over the markers. Where this repository has a touch map, `npx -y balladeer@latest affected\n<paths...>` answers which promises ran the files in front of you, out of what each verifier actually\nexecuted the last time `npx -y balladeer@latest touch-map` measured it. Both commands run on this machine and\nsend Balladeer nothing, so you may name any path in the change.\n\nAn answer marked stale was measured against a verifier that has changed since, so read it as the\nlast thing anybody measured rather than as fact, and offer to run `npx -y balladeer@latest touch-map` again.\nWhere there is no map, where it does not name your paths, or where it reports a promise it could not\nmeasure, fall back to the paths on the index rows. The map narrows which promises are worth\nfetching. It never widens what you may read: the catalog is still never enumerated, and the fetch\nbudget is unchanged.\n\nWhen a retrieval receipt asks why you read a row, the answer for one the map named is `touch`.\n\nFetch by id, and only for an id the person gave you or an index row whose paths match the change in\nfront of you. Never enumerate the catalog. Never chain one fetch into the next to see the whole of\nsomething: when the rows do not settle it, narrow the filter rather than expanding another promise.\n\n### When to say nothing, and what a yes is worth\n\nMost rules are said in passing, in the middle of something else. Somebody saying one has not asked\nyou to record anything, so propose nothing and start no interview. When you do ask, it is one line\nappended to the end of the reply you were already going to give, never a message of its own, never\nasked twice about the same rule, and nothing is proposed until they say yes. A no ends it, and that\nrule is not raised again for the rest of the conversation.\n\nSilence is per conversation rather than per message. Once a conversation is one of these, you ask\nnothing for the rest of it, however good the rule sounds:\n\n- A question, or working out how something already behaves. Nothing is filed and nothing is offered.\n- A refactor. Nobody predicts a promise from a rewrite. Offer the promises this area already carries\n that nothing is checking yet, once, and then wait. Ask nothing about new ones.\n- A change to wording alone. Wording somebody may change again tomorrow is not a rule.\n- An incident, while it is still being fixed. Ask nothing at all until the fix is merged or they say\n it is done, however good tonight's failing case would be.\n- Somebody still weighing options. A decision nobody has made yet is not a rule.\n- An exploration or spike. It ends in nothing or in a plan, and its sentences sound like rules and\n are not.\n- Reading somebody else's change, while you are still reading it. Nothing is offered until they\n give a verdict.\n\nThe words to ask in, the moment to offer, and the shape a proposal takes are not in this file. The\nBalladeer server sends them at the start of every session, and its copy is the current one: read\nwhat it sent this session rather than what this file remembers.\n\n### Two promises Balladeer cannot keep\n\nA promise about speed needs three things before it is a promise at all: a number, a percentile, and\nwhere it is measured. \"The quote page answers in under one second at p95, measured in production at\npeak load\" is one Balladeer can keep. \"The quote page has to be fast\" is not. Say this, and ask for\nthe part that is missing rather than filing it:\n\n\"I can keep that once it has a number, a percentile, and a place it is measured. Without those it is\na wish, not a promise.\"\n\nBalladeer refuses one without all three and names which of them is missing. When all three are\nthere, write the measurement method into the promise, and tell them plainly that it reads agreed and\nunprotected until a test that actually measures it exists.\n\nA promise about how the team works is not something the software does, so no check can ever catch\nit. \"We must provide a low-friction capture experience\" is one of these. File nothing and say:\n\n\"That is a promise about how we work, not something the software does that a check can fail.\nBalladeer only keeps promises a check can catch. If a customer would notice something when this\nslips, say that and I will keep that instead.\"\n\nThen take the customer-visible half if they give you one, and file that instead.\n\nRun `npx -y balladeer@latest session` when you start work here and pass the id it prints to every promise read\nyou make, as `session`. It is the same id for the whole of one piece of work, and it costs nothing:\na read without it is answered exactly the same way.\n\nIf local file permissions refuse that session write, no session stamp was saved. Your saved\nconnection is unchanged; this does not prove its current server authorization. Continue\nalready-authorized MCP reads, coding and explicitly requested capture without the optional\n`session` field. Do not invent an ID, add a session trailer, or record an unsaved stamp. Do not\nbroaden filesystem access or move credentials to retry this write. A missing credential or server\nauthorization refusal is different: follow that refusal. Capture still needs the person's request\nor accepted offer, and human meaning approval is unchanged.\n\nIf a session ID was saved, near the end of the work call `mark_promise_used` with the ids you actually cited\nor acted on. Naming none is a fine answer. It is the only way anybody finds out which rows were\nworth sending you, because reading a promise and then ignoring it is not the same as using it.\n\nIf a session ID was saved, write `Balladeer-Session: <the id>` into the commit message or pull-request body you produce,\nand run `npx -y balladeer@latest session --record` once the commit exists. That is what lets a check that goes red\nnext week be read back against what Balladeer told you before you started. Your commit message never\nleaves your machine: only the session id and the commit SHA are sent.\n\n### When somebody asks you to protect a behavior\n\nSomebody has asked you to protect a behavior when they say what the software must do, or must never\ndo again, and mean it as a rule rather than as this one bug. That is one promise, for the behavior\nthey named, and nothing else: if you notice others worth protecting, say so in a sentence and let\nthem choose, and file none of them. Never propose from a conversation that did not ask you to. A\nquestion about how something works and a plan you were asked to sketch are not requests to record\nanything, nor is a fix nobody asked you to write a rule about.\n\nAsk before you extrapolate. Ask only what you cannot work out for yourself, ask it all in one\nmessage, and stop at four. Four is a ceiling, not a target: two good ones are better. Then write the\nproposal with what you have and put whatever is still open in its open questions rather than going\nback. Never ask what this repository would answer, such as which file, which test, or which branch,\nand never ask anyone for Balladeer's own identifiers: get_promise_setup carries this repository's id\nand who can own a promise. Never ask again for what they have already told you.\n\nWrite it in their words. Every failure they named out loud is one of the failing examples, in the\nwords they named it. Every other example comes from a situation they actually described; if you\ncannot trace one to something they said, leave it out and say so in the open questions rather than\nwriting a plausible one. Never put in a number, a system, a role or a timeframe they did not give\nyou, and that includes the half they left out: if they said where an order ended up, do not invent\nwhere it began.\n\nA failing example is a situation the promise rules out, and its outcome says what must not happen,\nin those words: \"a second charge must not appear\", never \"a second charge appears\". Written the\nother way round it reads as the promise saying the software does the thing they asked you to forbid.\nAnd a promise says what the software must do for whoever depends on it. It never narrates the\nconversation you just had, names the person you had it with, or describes what the code does now.\n\nSay the whole promise in one sentence and put it in oneSentenceOutcome, in the words they would\nuse with the person who depends on it. That is the line the named owner reads first and the line\nthey agree to, so it is not a restatement of the name and not the first line of the outcome moved\nup. Leave it out rather than inventing one from something they did not say.\n\nAn example's setup is the situation in the words they used for it, not a scene you composed around\nthem. Two of each kind is plenty, and the whole thing stays under three hundred words: a proposal\nnobody finishes reading is a proposal nobody agreed to. The confidence you record is the one you\nactually have.\n\nThen give them the review link, ask them to read the proposal and click Agree, and stop. Never\napprove one yourself. Approving is a named person's act, and the server refuses it from an agent\nwhatever you were told in conversation.\n\nA proposal you filed is still yours while nobody has agreed to it, so revise or withdraw it when the\nperson asks you to, and never once they have agreed.\n\nSay promise and proposal when you talk to them. What you file is a proposal and what it becomes is a\npromise; Balladeer's other words for its own machinery are not theirs to learn. Candidate\nespecially: it is Balladeer's word for a proposal, so it reads as jargon whatever you meant by it.\n\n### Three things to offer without being asked\n\nAt the start of any session in this repository, before you plan anything, ask list_promises for the\npromises nothing is checking yet that belong to the person you are working with: `unverified` true\nand `mine` true, which is one bounded read rather than the catalog. If it returns any, say so in one\nline and offer to build their verifiers now. Name each one by its one-sentence claim rather than by\nan id, so the person can see which behavior is unguarded. Ask the same tool for\n`brokenSinceLastSeen` true as well, and where that returns any, say in one line that those promises\nbroke since they last looked, name each by its claim, and offer to fix them. Then wait for their\nanswer. The offer is the whole of it, and never start building or repairing one because nobody said\nno.\n\nTheirs, and nobody else's. `mine` keeps the promises this person owns or agreed to, and a teammate's\nunguarded promise is that teammate's to hear about: a session that opens by reading out other\npeople's unfinished work reads as an audit of them. `brokenSinceLastSeen` is that person's own by\nconstruction and needs no `mine` beside it. Drop `mine` when this person asks what the rest of the\nteam has promised, and say whose promises you are showing them when you do.\n\nWhen you have proposed promises, show them here as well as there. Put each proposal in the\nconversation in full: its one-sentence claim, who it is for, when it applies and what must then be\ntrue, the numbered cases that must keep working and the ones that must be caught, and every question\nyou left open. Then offer to finish the agreement from here, by asking for a sign-off, giving them\nthe page it returns and taking the one-time code that page shows them, so the browser is needed only\nfor signing. Give them the review link in the same message too, because some people would rather\nread it there and edit it before they agree.\nA promise whose meaning is agreed and which nothing is checking yet is one you can finish. When\nsomebody gives you a promise id, run `npx -y balladeer@latest status <promise id>`, or expand it with get_promise.\nIf it comes back agreed with nothing checking it, say so in one line and offer to prepare and build\nits verifier now. Only if they say yes: `prepare_qualification` mints the one-time setup, or\n`npx -y balladeer@latest prepare <promise id>` mints it and writes it where the sealed run reads it. You need no\nsign-off for that and there is no code to ask anybody for, because agreeing the meaning was their\nact and building the check that proves it is yours. Then build the verifier, seal it, push, and tell\nthem protection starts by itself when that run qualifies. The offer is the whole of it: never\nprepare one because nobody said no.\n\n### When somebody says \"tell me about\" one\n\nAn id is how a person points at something here, and every promise page and every proposal page\ncarries one. A promise id starts with prom_ and a proposal id starts with cand_: expand a promise\nwith get_promise and a proposal with get_proposal, and read neither of them out as a list of fields.\nSay in four or five sentences what it is for, who it is for, when it applies and what must then be\ntrue. Then say where it stands: a promise is agreed, and either protected or not yet checked by\nanything; a proposal is agreed by nobody and waiting on the person it names.\n\nClarify ambiguity that materially changes the behavior during capture, within the existing\nquestion budget; never invent an answer.\n\nOrdinary open questions record uncertainty; they do not prevent the named owner from agreeing to\nthe behavior as written. Agreement does not answer them or add an unstated guarantee. Separate\ncatalog-conflict questions can hold agreement until the owner rules on the stated conflict. Do not\ncall a proposal unready just because it has ordinary open questions. Offer to discuss them if\nuseful; if the person wants to, take them one at a time in the order they come back. For each ordinary question,\nsay what it decides in their words rather than in the question's; give your\nrecommendation and the reason you hold it, drawn from this repository and from the promise itself; and stop there. When\nthey answer, record what they said with resolve_question, in their own words where they gave you\nany, and where their answer changes the promise, follow it with update_proposal, add_case or\nremove_case and tell them what you changed. Never invent an answer or clear uncertainty because\nthey chose to agree. None of that agrees to anything: the named owner agrees, in their own browser\nor through a sign-off you carry, and the questions they answered stay on the proposal in their name.\n\nThe same rule governs every question you leave open in the first place. A question that names a gap\nand stops is a note, and nobody can answer a note. Say what answering it decides, in the words a\ncustomer would use, and carry your own best guess with the reason behind it, so that the shortest\ntrue answer is yes. For example: \"Decides: whether a worker that is running but reconciling nothing\ncounts as an outage this promise covers. Best guess: yes, because the promise is about somebody\nhearing before a customer does, and a wedged worker is invisible to every check this repository has.\nSay yes, or tell me otherwise.\" Balladeer refuses a question filed without both halves and says\nwhich one is missing.\n\n### Files that are sealed, and the one reason to edit one\n\nEvery file under .continuity/promises/ is sealed. The promise that owns that directory records the\nexact bytes of each file in it, so editing one, adding one there, renaming one or deleting one\nbreaks the seal. A promise whose seal is broken stops being checked, and it stays that way until a\nnamed person qualifies it again, which is their afternoon rather than your commit. Nothing in there\nis ordinary source: keep it out of refactors, formatting runs and dependency upgrades.\n\nFind out which directories are sealed before you plan an edit, not after. npx -y balladeer@latest status lists\nthem, and list_promises names a promise's sealed directory on its row once a verifier is bound to\nit. Then, before you push, run npx -y balladeer@latest check-seals. It prints nothing and exits zero when your\nchange touches no seal, and names the promise, its owner and its page when your change would break\none.\n\nThe one reason to edit a sealed file is to repair a verifier that can no longer run: something it\nimports moved, or the language it is written in changed under it. Never edit one to make a failing\ncheck pass. A check going red is the promise doing its job, and the repair for that belongs in the\nbehavior it protects. A repair is not finished until the promise is sealed again with the runner\nthis repository is pinned to. get_promise_setup carries that exact seal command, and\nnpx -y balladeer@latest check-seals prints it beside any promise it names.\n\n### The rest of a promise's life\nWhen a promise is obsolete, finished, deliberately off for a while, or owned by the wrong person,\npropose the change and hand them the promise page. Deciding is theirs.\n\nYou can finish one of those acts here, and only one way. Ask for a sign-off with\nrequest_owner_signoff, give them the page it returns, and ask for the one-time code that page shows\nthem. Then call the act's own tool with that code and their own words. Never call one on your own\ninitiative, never on a general approval of some earlier act, and never ask for a code you were not\ngiven: a refusal is the person's to resolve, not yours to retry. Balladeer records them as the\nperson who acted and you as the messenger.\n\nReport the promise's state exactly as Balladeer reported it: proposed, agreed, or protected, never\none in place of another.\n\n### Where your team watches this\n\nBalladeer is a web app as well as these tools, at the address setup printed. Its catalog lists every\npromise with who owns it and whether anything is checking it, and each promise has a page of its own\nshowing what this team agreed the software must do and then every run that has checked it since,\nnewest first, with the commit each one checked. Whenever there is a link to give, give the link\nrather than a summary of it: the page says what you would have said, and it stays true after this\nconversation has ended.\n\nBalladeer also has a Slack app, which a workspace administrator installs from workspace settings.\nOnce it is installed, whoever owns a promise gets a direct message when theirs goes live and when a\nrun on the default branch breaks it. Until somebody installs it, nothing is sent anywhere, so say it\nis available rather than saying they will be told.\n\n### Questions people ask\n\nAnswer these when they come up. Where you do not know, say so and point at the address setup\nprinted: a confident wrong answer about what a vendor can see is worse than no answer.\n\nWhat it does: it holds the behaviors this team has agreed the software must keep, and reports\nwhether each one is still being kept, from this repository's own tests running in its own CI.\n\nWhat it sees: the text of each promise somebody approves, this repository's numeric ids and the name\nof its default branch, and from CI the pass or fail outcome, the commit checked, and content hashes.\nNever the code, the tests, the fixtures, the logs, the prompts, or the transcripts.\n\nWhat stopping costs: nothing that matters to their tests. The verifier package, its fixtures and the\nworkflow file are theirs, in their repository, running in their CI, and disconnecting changes none\nof them. An administrator can download everything Balladeer holds at any time from workspace\nsettings, and disconnecting hands them that same download in the response that ends access.\n\nWho can approve one: the named person who owns it, in their own browser. Not an administrator on\ntheir behalf, and never you.\n\nWhether it blocks a merge: no. The check is advisory on Balladeer's side, and their own branch\nprotection is what decides whether a failing check stops anything.\n\nWho can invite people and change setup: a workspace administrator. A contributor can read the\nworkspace and propose promises, and a viewer can read it. If somebody asks you to add a teammate,\ninvite_teammate returns the page an administrator sends the invitation from, with the address filled\nin for them to read. The tool sends nothing itself: an administrator presses Send.";
197
197
  /**
198
198
  * How an agent turns a repository that has just been connected into a catalog a
199
199
  * person can read. Setup's last step prints it, and the control plane's `/agent`
package/dist/copy.js CHANGED
@@ -289,15 +289,21 @@ Say in four or five sentences what it is for, who it is for, when it applies and
289
289
  true. Then say where it stands: a promise is agreed, and either protected or not yet checked by
290
290
  anything; a proposal is agreed by nobody and waiting on the person it names.
291
291
 
292
- A proposal that still carries open questions is not finished, and settling them is usually why
293
- somebody asked. Offer to work through them, then take them one at a time in the order they come
294
- back. For each one, say what it decides in their words rather than in the question's; give your
295
- recommendation and the reason you hold it, drawn from this repository and from the promise itself;
296
- and stop there. When they answer, record what they said with resolve_question, in their own words
297
- where they gave you any, and where their answer changes the promise, follow it with update_proposal,
298
- add_case or remove_case and tell them what you changed. None of that agrees to anything: the named
299
- owner agrees, in their own browser or through a sign-off you carry, and the questions they answered
300
- stay on the proposal in their name.
292
+ Clarify ambiguity that materially changes the behavior during capture, within the existing
293
+ question budget; never invent an answer.
294
+
295
+ Ordinary open questions record uncertainty; they do not prevent the named owner from agreeing to
296
+ the behavior as written. Agreement does not answer them or add an unstated guarantee. Separate
297
+ catalog-conflict questions can hold agreement until the owner rules on the stated conflict. Do not
298
+ call a proposal unready just because it has ordinary open questions. Offer to discuss them if
299
+ useful; if the person wants to, take them one at a time in the order they come back. For each ordinary question,
300
+ say what it decides in their words rather than in the question's; give your
301
+ recommendation and the reason you hold it, drawn from this repository and from the promise itself; and stop there. When
302
+ they answer, record what they said with resolve_question, in their own words where they gave you
303
+ any, and where their answer changes the promise, follow it with update_proposal, add_case or
304
+ remove_case and tell them what you changed. Never invent an answer or clear uncertainty because
305
+ they chose to agree. None of that agrees to anything: the named owner agrees, in their own browser
306
+ or through a sign-off you carry, and the questions they answered stay on the proposal in their name.
301
307
 
302
308
  The same rule governs every question you leave open in the first place. A question that names a gap
303
309
  and stops is a note, and nobody can answer a note. Say what answering it decides, in the words a
package/dist/store.d.ts CHANGED
@@ -11,8 +11,12 @@ export type PendingPairing = Readonly<{
11
11
  verificationUri: string;
12
12
  expiresAt: string;
13
13
  startedAt: string;
14
+ /** Local pairing intent; lets an explicit workspace choice resume its own code. */
15
+ workspaceChoice?: string;
14
16
  }>;
15
17
  export type StoredSession = Readonly<{
18
+ /** Claimed local create intent, not workspace identity or server authority. */
19
+ workspaceChoice?: string;
16
20
  controlPlane: string;
17
21
  sessionId: string;
18
22
  token: string;
@@ -74,6 +78,8 @@ export declare function credentialsPath(environment?: NodeJS.ProcessEnv): string
74
78
  */
75
79
  export declare function assertSafeStoreLocation(directory: string, environment?: NodeJS.ProcessEnv, cwd?: string): void;
76
80
  export declare function ensureStoreDirectory(environment?: NodeJS.ProcessEnv): string;
81
+ /** Check actual write permission before requesting a pairing whose secret must be saved. */
82
+ export declare function assertStoreWritable(environment?: NodeJS.ProcessEnv): void;
77
83
  export declare function readCredentials(environment?: NodeJS.ProcessEnv): Credentials;
78
84
  /**
79
85
  * Atomic, and never through a path an attacker could have pre-planted: the
package/dist/store.js CHANGED
@@ -86,12 +86,38 @@ function assertPrivate(path, kind) {
86
86
  throw new StoreError("credential_store_unsafe", `${path} is readable by other users. Run: chmod ${kind === "directory" ? "700" : "600"} ${path}`);
87
87
  }
88
88
  }
89
+ function storeAccessError(error, path, operation) {
90
+ if (error instanceof StoreError)
91
+ return error;
92
+ const code = error?.code;
93
+ const permissionDenied = code === "EACCES" || code === "EPERM";
94
+ return new StoreError(operation === "read" ? "credential_store_unreadable" : "credential_store_unwritable", permissionDenied
95
+ ? `Balladeer cannot ${operation} its local credential store at ${path}. Allow this command to access that private folder in your terminal or coding agent, then rerun the same command. Existing saved credentials were not replaced.`
96
+ : `Balladeer could not ${operation} its local credential store at ${path} (${code ?? "filesystem error"}). Check that this location is a private, accessible directory, then retry. Existing saved credentials were not replaced.`, { cause: error });
97
+ }
89
98
  export function ensureStoreDirectory(environment = process.env) {
90
99
  const directory = configHome(environment);
91
- assertSafeStoreLocation(directory, environment);
92
- mkdirSync(directory, { recursive: true, mode: 0o700 });
93
- assertPrivate(directory, "directory");
94
- return directory;
100
+ try {
101
+ assertSafeStoreLocation(directory, environment);
102
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
103
+ assertPrivate(directory, "directory");
104
+ return directory;
105
+ }
106
+ catch (error) {
107
+ throw storeAccessError(error, directory, "write");
108
+ }
109
+ }
110
+ /** Check actual write permission before requesting a pairing whose secret must be saved. */
111
+ export function assertStoreWritable(environment = process.env) {
112
+ const probe = `.write-check-${randomBytes(8).toString("hex")}`;
113
+ const path = join(configHome(environment), probe);
114
+ try {
115
+ writeStoreFile(probe, "", environment);
116
+ unlinkSync(path);
117
+ }
118
+ catch (error) {
119
+ throw storeAccessError(error, configHome(environment), "write");
120
+ }
95
121
  }
96
122
  export function readCredentials(environment = process.env) {
97
123
  const path = credentialsPath(environment);
@@ -101,9 +127,10 @@ export function readCredentials(environment = process.env) {
101
127
  raw = readFileSync(path, "utf8");
102
128
  }
103
129
  catch (error) {
104
- if (error instanceof StoreError)
105
- throw error;
106
- return { ...EMPTY, pendingPairings: [], sessions: [], agents: [] };
130
+ if (error?.code === "ENOENT") {
131
+ return { ...EMPTY, pendingPairings: [], sessions: [], agents: [] };
132
+ }
133
+ throw storeAccessError(error, path, "read");
107
134
  }
108
135
  try {
109
136
  const parsed = JSON.parse(raw);
@@ -164,9 +191,7 @@ export function writeStoreFile(fileName, contents, environment = process.env) {
164
191
  catch {
165
192
  // The temporary file may never have been created.
166
193
  }
167
- if (error instanceof StoreError)
168
- throw error;
169
- throw new StoreError("credential_store_unwritable", `Balladeer could not write ${path}: ${error instanceof Error ? error.message : "unknown reason"}`, { cause: error });
194
+ throw storeAccessError(error, path, "write");
170
195
  }
171
196
  }
172
197
  /** Origins, not URL strings, so `https://host/../` cannot alias a stored entry. */
package/dist/wire.d.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export declare const CLI_VERSION = "1.0.2";
8
+ export declare const CLI_VERSION = "1.0.4";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.2";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.4";
12
12
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
13
13
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
14
14
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export const CLI_VERSION = "1.0.2";
8
+ export const CLI_VERSION = "1.0.4";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -26,5 +26,8 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "typescript": "5.9.3"
29
+ },
30
+ "dependencies": {
31
+ "@iarna/toml": "2.2.5"
29
32
  }
30
33
  }