balladeer 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -32
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +13 -1
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +181 -24
- package/dist/client.d.ts +24 -2
- package/dist/client.js +34 -3
- package/dist/commands/affected.js +8 -7
- package/dist/commands/check-seals.js +4 -4
- package/dist/commands/discover.js +16 -7
- package/dist/commands/explain.d.ts +1 -1
- package/dist/commands/explain.js +1 -1
- package/dist/commands/invite.js +2 -1
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +218 -0
- package/dist/commands/propose.d.ts +10 -0
- package/dist/commands/propose.js +29 -6
- package/dist/commands/repositories.js +1 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +131 -0
- package/dist/commands/setup.d.ts +29 -0
- package/dist/commands/setup.js +302 -92
- package/dist/commands/status.d.ts +16 -0
- package/dist/commands/status.js +106 -23
- package/dist/commands/touch-map.js +2 -2
- package/dist/commands/whoami.js +2 -1
- package/dist/conventions.d.ts +9 -1
- package/dist/conventions.js +9 -1
- package/dist/copy.d.ts +83 -7
- package/dist/copy.js +226 -29
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/git.d.ts +15 -0
- package/dist/git.js +23 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/mcp-config.d.ts +10 -0
- package/dist/mcp-config.js +8 -4
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +11 -1
- package/dist/store.js +18 -6
- package/dist/wire.d.ts +95 -4
- package/dist/wire.js +2 -1
- package/package.json +1 -1
package/dist/copy.js
CHANGED
|
@@ -21,23 +21,35 @@ test output.
|
|
|
21
21
|
The verify job in your CI runs your tests with your checkout and has no Balladeer credential. A
|
|
22
22
|
separate publish job, with no checkout, sends only those outcomes and hashes.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
Browser approval creates a temporary setup session limited by that person's workspace role. A
|
|
25
|
+
contributor can connect this machine's coding agent to an already-enrolled repository and propose
|
|
26
|
+
promises; an administrator can also add repositories, connect CI, and invite teammates. The session
|
|
27
|
+
cannot approve, activate, rotate, revoke, or remove anything. It lasts a day, each use buys it
|
|
28
|
+
another day, and it is gone seven days after approval however much it was used. When setup connects
|
|
29
|
+
an enrolled repository it also issues that machine a coding-agent credential. That connection has no
|
|
30
|
+
expiry: it lets the agent read the promises your team has approved, propose new ones, and propose
|
|
31
|
+
replacing, retiring, excepting, or reassigning one you already have; every one of those waits for a
|
|
32
|
+
named person to decide it. It can also carry out one of those acts for you, and only this way: you
|
|
33
|
+
open a Balladeer page in your own browser, read what the act is, sign it off there, and read back
|
|
34
|
+
the one-time code that page gives you. That code covers that one act on that one thing, once, and
|
|
35
|
+
Balladeer records you as the person who took it and the connection as the messenger. Without a code
|
|
36
|
+
you signed, the connection cannot approve, activate, grant, transfer, retire, or delete anything.
|
|
37
|
+
You can revoke either one at any time in Balladeer, and the setup session also ends on its own.
|
|
37
38
|
|
|
38
39
|
Adding the workflow puts a check on pull requests into your default branch. That check is advisory
|
|
39
40
|
on Balladeer's side; whether it blocks a merge is your own branch protection.
|
|
40
41
|
`;
|
|
42
|
+
/**
|
|
43
|
+
* The one thing about approving that the terminal has to say.
|
|
44
|
+
*
|
|
45
|
+
* A person who belongs to two workspaces approves into whichever one their
|
|
46
|
+
* browser is signed into, and it is the browser that decides, not the terminal.
|
|
47
|
+
* Twice that was the wrong one: the repository was enrolled in a workspace
|
|
48
|
+
* nobody meant and an agent connection was minted there. The approval page now
|
|
49
|
+
* names the workspace above the button and offers the switch, and this line is
|
|
50
|
+
* what makes somebody look at it before they click.
|
|
51
|
+
*/
|
|
52
|
+
export const APPROVE_IN_THE_RIGHT_WORKSPACE = "Approve it while your browser is in the workspace you mean; the page names it.";
|
|
41
53
|
/**
|
|
42
54
|
* How a person gets a workspace to approve into, for an agent reading the
|
|
43
55
|
* setup instructions.
|
|
@@ -55,12 +67,18 @@ on Balladeer's side; whether it blocks a merge is your own branch protection.
|
|
|
55
67
|
* and the terminal it is reading cannot come to describe this differently.
|
|
56
68
|
*/
|
|
57
69
|
export const JOIN_OR_CREATE = `The pairing page joins an existing workspace or makes a new one. Somebody whose team already has
|
|
58
|
-
a Balladeer workspace
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
a Balladeer workspace accepts their email invitation, then runs \`npx -y balladeer@latest setup --existing\` in
|
|
71
|
+
the checkout they mean to use. That teammate mode never adds a repository or changes CI. After they
|
|
72
|
+
sign in and approve the code, onboarding continues even when the checkout is not in the workspace.
|
|
73
|
+
If it is already enrolled, setup connects this machine's coding agent. If it is absent, the person
|
|
74
|
+
can still use Balladeer in the browser; a red warning explains that enforcement cannot be tracked
|
|
75
|
+
until an administrator connects a repository.
|
|
76
|
+
|
|
77
|
+
Somebody who is the first person from their team signs in, names a workspace, and clicks Create, and
|
|
78
|
+
only then is there anything to approve into.
|
|
61
79
|
|
|
62
80
|
You can save them the typing, and nothing else. Ask what the workspace should be called, then run
|
|
63
|
-
\`balladeer setup --create-workspace "<name>"\` with the name they gave you. The pairing link this
|
|
81
|
+
\`npx -y balladeer@latest setup --create-workspace "<name>"\` with the name they gave you. The pairing link this
|
|
64
82
|
command prints then carries \`?create=<name>\`, which pre-fills that name in the create form on the
|
|
65
83
|
pairing page. It fills the box and no more than that: the person signs in and clicks Create, and
|
|
66
84
|
until they do there is no workspace. An agent never creates one, and Balladeer refuses the attempt
|
|
@@ -135,7 +153,7 @@ seen everything.`;
|
|
|
135
153
|
* plane's `/agent` page carries the same bytes under its own constant, and a
|
|
136
154
|
* packaging test compares them.
|
|
137
155
|
*/
|
|
138
|
-
export const SESSION_BEHAVIORS = `###
|
|
156
|
+
export const SESSION_BEHAVIORS = `### Three things to offer without being asked
|
|
139
157
|
|
|
140
158
|
At the start of any session in this repository, before you plan anything, ask list_promises for the
|
|
141
159
|
promises nothing is checking yet that belong to the person you are working with: \`unverified\` true
|
|
@@ -159,7 +177,136 @@ true, the numbered cases that must keep working and the ones that must be caught
|
|
|
159
177
|
you left open. Then offer to finish the agreement from here, by asking for a sign-off, giving them
|
|
160
178
|
the page it returns and taking the one-time code that page shows them, so the browser is needed only
|
|
161
179
|
for signing. Give them the review link in the same message too, because some people would rather
|
|
162
|
-
read it there and edit it before they agree
|
|
180
|
+
read it there and edit it before they agree.
|
|
181
|
+
A promise whose meaning is agreed and which nothing is checking yet is one you can finish. When
|
|
182
|
+
somebody gives you a promise id, run \`npx -y balladeer@latest status <promise id>\`, or expand it with get_promise.
|
|
183
|
+
If it comes back agreed with nothing checking it, say so in one line and offer to prepare and build
|
|
184
|
+
its verifier now. Only if they say yes: \`prepare_qualification\` mints the one-time setup, or
|
|
185
|
+
\`npx -y balladeer@latest prepare <promise id>\` mints it and writes it where the sealed run reads it. You need no
|
|
186
|
+
sign-off for that and there is no code to ask anybody for, because agreeing the meaning was their
|
|
187
|
+
act and building the check that proves it is yours. Then build the verifier, seal it, push, and tell
|
|
188
|
+
them protection starts by itself when that run qualifies. The offer is the whole of it: never
|
|
189
|
+
prepare one because nobody said no.`;
|
|
190
|
+
/**
|
|
191
|
+
* The capture rules that do not change: when to say nothing, what a yes is
|
|
192
|
+
* worth, and the two promises Balladeer refuses.
|
|
193
|
+
*
|
|
194
|
+
* This block is written into the customer's own instructions file once, at
|
|
195
|
+
* setup, and is never refreshed afterwards. Changing it means opening a pull
|
|
196
|
+
* request against twelve Didero repositories, so only what will still be true
|
|
197
|
+
* in six months belongs here. Silence during an incident, a no that ends it, and
|
|
198
|
+
* nothing filed without a yes are that kind of rule. The words a question is
|
|
199
|
+
* asked in, the moment an offer is made, and the shape a proposal takes are not:
|
|
200
|
+
* they are read every week against what people actually agreed to, and they ride
|
|
201
|
+
* in `CAPTURE_BEHAVIOR`, which the server sends at the start of every session.
|
|
202
|
+
*
|
|
203
|
+
* Nothing here weakens the explicit-capture rule: the question is a question,
|
|
204
|
+
* and nothing is proposed until the answer is yes.
|
|
205
|
+
*
|
|
206
|
+
* The two refusals are here rather than only in the server because an agent that
|
|
207
|
+
* learns them only by being refused has already spent the person's attention on
|
|
208
|
+
* a proposal that could not be kept. The server refuses both as well, and the
|
|
209
|
+
* sentences are the same ones.
|
|
210
|
+
*
|
|
211
|
+
* Byte for byte the same text as `AGENT_INSTRUCTIONS_CAPTURE_SEAM` in the
|
|
212
|
+
* control plane, and contained byte for byte inside both server carriers. A
|
|
213
|
+
* packaging test holds all three together: the block written into a customer's
|
|
214
|
+
* repository, the instructions the MCP server sends on initialize, and the page
|
|
215
|
+
* an agent fetches cannot come to say different things about when to stay quiet.
|
|
216
|
+
*/
|
|
217
|
+
export const CAPTURE_SEAM = `### When to say nothing, and what a yes is worth
|
|
218
|
+
|
|
219
|
+
Most rules are said in passing, in the middle of something else. Somebody saying one has not asked
|
|
220
|
+
you to record anything, so propose nothing and start no interview. When you do ask, it is one line
|
|
221
|
+
appended to the end of the reply you were already going to give, never a message of its own, never
|
|
222
|
+
asked twice about the same rule, and nothing is proposed until they say yes. A no ends it, and that
|
|
223
|
+
rule is not raised again for the rest of the conversation.
|
|
224
|
+
|
|
225
|
+
Silence is per conversation rather than per message. Once a conversation is one of these, you ask
|
|
226
|
+
nothing for the rest of it, however good the rule sounds:
|
|
227
|
+
|
|
228
|
+
- A question, or working out how something already behaves. Nothing is filed and nothing is offered.
|
|
229
|
+
- A refactor. Nobody predicts a promise from a rewrite. Offer the promises this area already carries
|
|
230
|
+
that nothing is checking yet, once, and then wait. Ask nothing about new ones.
|
|
231
|
+
- A change to wording alone. Wording somebody may change again tomorrow is not a rule.
|
|
232
|
+
- An incident, while it is still being fixed. Ask nothing at all until the fix is merged or they say
|
|
233
|
+
it is done, however good tonight's failing case would be.
|
|
234
|
+
- Somebody still weighing options. A decision nobody has made yet is not a rule.
|
|
235
|
+
- An exploration or spike. It ends in nothing or in a plan, and its sentences sound like rules and
|
|
236
|
+
are not.
|
|
237
|
+
- Reading somebody else's change, while you are still reading it. Nothing is offered until they
|
|
238
|
+
give a verdict.
|
|
239
|
+
|
|
240
|
+
The words to ask in, the moment to offer, and the shape a proposal takes are not in this file. The
|
|
241
|
+
Balladeer server sends them at the start of every session, and its copy is the current one: read
|
|
242
|
+
what it sent this session rather than what this file remembers.
|
|
243
|
+
|
|
244
|
+
### Two promises Balladeer cannot keep
|
|
245
|
+
|
|
246
|
+
A promise about speed needs three things before it is a promise at all: a number, a percentile, and
|
|
247
|
+
where it is measured. "The quote page answers in under one second at p95, measured in production at
|
|
248
|
+
peak load" is one Balladeer can keep. "The quote page has to be fast" is not. Say this, and ask for
|
|
249
|
+
the part that is missing rather than filing it:
|
|
250
|
+
|
|
251
|
+
"I can keep that once it has a number, a percentile, and a place it is measured. Without those it is
|
|
252
|
+
a wish, not a promise."
|
|
253
|
+
|
|
254
|
+
Balladeer refuses one without all three and names which of them is missing. When all three are
|
|
255
|
+
there, write the measurement method into the promise, and tell them plainly that it reads agreed and
|
|
256
|
+
unprotected until a test that actually measures it exists.
|
|
257
|
+
|
|
258
|
+
A promise about how the team works is not something the software does, so no check can ever catch
|
|
259
|
+
it. "We must provide a low-friction capture experience" is one of these. File nothing and say:
|
|
260
|
+
|
|
261
|
+
"That is a promise about how we work, not something the software does that a check can fail.
|
|
262
|
+
Balladeer only keeps promises a check can catch. If a customer would notice something when this
|
|
263
|
+
slips, say that and I will keep that instead."
|
|
264
|
+
|
|
265
|
+
Then take the customer-visible half if they give you one, and file that instead.`;
|
|
266
|
+
/**
|
|
267
|
+
* How an agent describes one promise or proposal to a person, and how the two
|
|
268
|
+
* of them settle what is still open on it.
|
|
269
|
+
*
|
|
270
|
+
* The first proposal a founder was ever asked to settle carried four questions
|
|
271
|
+
* that were all true and none of them answerable: each named a hole in the
|
|
272
|
+
* world, and none said what answering it would decide or what the agent would
|
|
273
|
+
* do if nobody replied. So this says both halves of the same rule. When a
|
|
274
|
+
* person points at something by id, describe it and offer to work through what
|
|
275
|
+
* is open; and when you leave a question open, leave one that can be answered
|
|
276
|
+
* with a yes.
|
|
277
|
+
*
|
|
278
|
+
* Three carriers say it byte for byte: the block setup writes into the
|
|
279
|
+
* customer's own instructions file, the instructions the MCP server sends on
|
|
280
|
+
* initialize, and the page `/agent` serves. A packaging test holds them
|
|
281
|
+
* together.
|
|
282
|
+
*/
|
|
283
|
+
export const SETTLING_QUESTIONS = `### When somebody says "tell me about" one
|
|
284
|
+
|
|
285
|
+
An id is how a person points at something here, and every promise page and every proposal page
|
|
286
|
+
carries one. A promise id starts with prom_ and a proposal id starts with cand_: expand a promise
|
|
287
|
+
with get_promise and a proposal with get_proposal, and read neither of them out as a list of fields.
|
|
288
|
+
Say in four or five sentences what it is for, who it is for, when it applies and what must then be
|
|
289
|
+
true. Then say where it stands: a promise is agreed, and either protected or not yet checked by
|
|
290
|
+
anything; a proposal is agreed by nobody and waiting on the person it names.
|
|
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.
|
|
301
|
+
|
|
302
|
+
The same rule governs every question you leave open in the first place. A question that names a gap
|
|
303
|
+
and stops is a note, and nobody can answer a note. Say what answering it decides, in the words a
|
|
304
|
+
customer would use, and carry your own best guess with the reason behind it, so that the shortest
|
|
305
|
+
true answer is yes. For example: "Decides: whether a worker that is running but reconciling nothing
|
|
306
|
+
counts as an outage this promise covers. Best guess: yes, because the promise is about somebody
|
|
307
|
+
hearing before a customer does, and a wedged worker is invisible to every check this repository has.
|
|
308
|
+
Say yes, or tell me otherwise." Balladeer refuses a question filed without both halves and says
|
|
309
|
+
which one is missing.`;
|
|
163
310
|
/**
|
|
164
311
|
* What a session may spend on reading, in three sentences.
|
|
165
312
|
*
|
|
@@ -204,9 +351,9 @@ breaks the seal. A promise whose seal is broken stops being checked, and it stay
|
|
|
204
351
|
named person qualifies it again, which is their afternoon rather than your commit. Nothing in there
|
|
205
352
|
is ordinary source: keep it out of refactors, formatting runs and dependency upgrades.
|
|
206
353
|
|
|
207
|
-
Find out which directories are sealed before you plan an edit, not after. balladeer status lists
|
|
354
|
+
Find out which directories are sealed before you plan an edit, not after. npx -y balladeer@latest status lists
|
|
208
355
|
them, and list_promises names a promise's sealed directory on its row once a verifier is bound to
|
|
209
|
-
it. Then, before you push, run balladeer check-seals. It prints nothing and exits zero when your
|
|
356
|
+
it. Then, before you push, run npx -y balladeer@latest check-seals. It prints nothing and exits zero when your
|
|
210
357
|
change touches no seal, and names the promise, its owner and its page when your change would break
|
|
211
358
|
one.
|
|
212
359
|
|
|
@@ -214,8 +361,8 @@ The one reason to edit a sealed file is to repair a verifier that can no longer
|
|
|
214
361
|
imports moved, or the language it is written in changed under it. Never edit one to make a failing
|
|
215
362
|
check pass. A check going red is the promise doing its job, and the repair for that belongs in the
|
|
216
363
|
behavior it protects. A repair is not finished until the promise is sealed again with the runner
|
|
217
|
-
this repository is pinned to. get_promise_setup carries that exact seal command, and
|
|
218
|
-
check-seals prints it beside any promise it names.`;
|
|
364
|
+
this repository is pinned to. get_promise_setup carries that exact seal command, and
|
|
365
|
+
npx -y balladeer@latest check-seals prints it beside any promise it names.`;
|
|
219
366
|
/**
|
|
220
367
|
* Which promises a change touches, measured rather than guessed.
|
|
221
368
|
*
|
|
@@ -232,19 +379,57 @@ check-seals prints it beside any promise it names.`;
|
|
|
232
379
|
* Three carriers say it, byte for byte, and a packaging test holds them
|
|
233
380
|
* together.
|
|
234
381
|
*/
|
|
235
|
-
export const TOUCH_MAP_GUIDANCE = `Prefer the measurement over the markers. Where this repository has a touch map, \`balladeer affected
|
|
382
|
+
export const TOUCH_MAP_GUIDANCE = `Prefer the measurement over the markers. Where this repository has a touch map, \`npx -y balladeer@latest affected
|
|
236
383
|
<paths...>\` answers which promises ran the files in front of you, out of what each verifier actually
|
|
237
|
-
executed the last time \`balladeer touch-map\` measured it. Both commands run on this machine and
|
|
384
|
+
executed the last time \`npx -y balladeer@latest touch-map\` measured it. Both commands run on this machine and
|
|
238
385
|
send Balladeer nothing, so you may name any path in the change.
|
|
239
386
|
|
|
240
387
|
An answer marked stale was measured against a verifier that has changed since, so read it as the
|
|
241
|
-
last thing anybody measured rather than as fact, and offer to run \`balladeer touch-map\` again.
|
|
388
|
+
last thing anybody measured rather than as fact, and offer to run \`npx -y balladeer@latest touch-map\` again.
|
|
242
389
|
Where there is no map, where it does not name your paths, or where it reports a promise it could not
|
|
243
390
|
measure, fall back to the paths on the index rows. The map narrows which promises are worth
|
|
244
391
|
fetching. It never widens what you may read: the catalog is still never enumerated, and the fetch
|
|
245
392
|
budget is unchanged.
|
|
246
393
|
|
|
247
394
|
When a retrieval receipt asks why you read a row, the answer for one the map named is \`touch\`.`;
|
|
395
|
+
/**
|
|
396
|
+
* The one paragraph that turns retrieval from a thing we argue about into a
|
|
397
|
+
* thing we measure.
|
|
398
|
+
*
|
|
399
|
+
* Two facts are asked for and neither can be observed from this side. Which
|
|
400
|
+
* rows an agent actually used, because a promise expanded and then ignored is
|
|
401
|
+
* not a row the index earned. And which change the session went on to write,
|
|
402
|
+
* because a retrieval read happens before the commit exists and nothing joins
|
|
403
|
+
* the two afterwards: Balladeer receives commit SHAs from CI and never a commit
|
|
404
|
+
* message, so the agent is the only thing present at both ends.
|
|
405
|
+
*
|
|
406
|
+
* It is written as three instructions rather than as an explanation, because
|
|
407
|
+
* what an agent does with it is three specific acts, and a paragraph about
|
|
408
|
+
* measurement would be read and not acted on.
|
|
409
|
+
*
|
|
410
|
+
* Three carriers say it, byte for byte, and a packaging test holds them
|
|
411
|
+
* together.
|
|
412
|
+
*/
|
|
413
|
+
export const SESSION_STAMP = `Run \`npx -y balladeer@latest session\` when you start work here and pass the id it prints to every promise read
|
|
414
|
+
you make, as \`session\`. It is the same id for the whole of one piece of work, and it costs nothing:
|
|
415
|
+
a read without it is answered exactly the same way.
|
|
416
|
+
|
|
417
|
+
If local file permissions refuse that session write, no session stamp was saved. Your saved
|
|
418
|
+
connection is unchanged; this does not prove its current server authorization. Continue
|
|
419
|
+
already-authorized MCP reads, coding and explicitly requested capture without the optional
|
|
420
|
+
\`session\` field. Do not invent an ID, add a session trailer, or record an unsaved stamp. Do not
|
|
421
|
+
broaden filesystem access or move credentials to retry this write. A missing credential or server
|
|
422
|
+
authorization refusal is different: follow that refusal. Capture still needs the person's request
|
|
423
|
+
or accepted offer, and human meaning approval is unchanged.
|
|
424
|
+
|
|
425
|
+
If a session ID was saved, near the end of the work call \`mark_promise_used\` with the ids you actually cited
|
|
426
|
+
or acted on. Naming none is a fine answer. It is the only way anybody finds out which rows were
|
|
427
|
+
worth sending you, because reading a promise and then ignoring it is not the same as using it.
|
|
428
|
+
|
|
429
|
+
If a session ID was saved, write \`Balladeer-Session: <the id>\` into the commit message or pull-request body you produce,
|
|
430
|
+
and run \`npx -y balladeer@latest session --record\` once the commit exists. That is what lets a check that goes red
|
|
431
|
+
next week be read back against what Balladeer told you before you started. Your commit message never
|
|
432
|
+
leaves your machine: only the session id and the commit SHA are sent.`;
|
|
248
433
|
/**
|
|
249
434
|
* The conventions block written between markers into this repository's agent
|
|
250
435
|
* instructions file. Fixed text, compared byte for byte by a test, because a
|
|
@@ -267,6 +452,10 @@ ${TOUCH_MAP_GUIDANCE}
|
|
|
267
452
|
|
|
268
453
|
${FETCH_BUDGET}
|
|
269
454
|
|
|
455
|
+
${CAPTURE_SEAM}
|
|
456
|
+
|
|
457
|
+
${SESSION_STAMP}
|
|
458
|
+
|
|
270
459
|
### When somebody asks you to protect a behavior
|
|
271
460
|
|
|
272
461
|
Somebody has asked you to protect a behavior when they say what the software must do, or must never
|
|
@@ -319,6 +508,8 @@ especially: it is Balladeer's word for a proposal, so it reads as jargon whateve
|
|
|
319
508
|
|
|
320
509
|
${SESSION_BEHAVIORS}
|
|
321
510
|
|
|
511
|
+
${SETTLING_QUESTIONS}
|
|
512
|
+
|
|
322
513
|
${SEALED_FILES}
|
|
323
514
|
|
|
324
515
|
### The rest of a promise's life
|
|
@@ -442,15 +633,21 @@ For each promise you propose:
|
|
|
442
633
|
actually keeps and that you have described it as its source describes it. Nine promises from nine
|
|
443
634
|
sources are not nine equally certain readings, and the person reading them is entitled to know
|
|
444
635
|
which one you were least sure of. Never write 1 to get past the check.
|
|
636
|
+
- One wrongOutcome sentence: what going wrong looks like, said as a must-not, in the source's own
|
|
637
|
+
words where it has them. "A second payment must not go out." "The buyer must not see a generic
|
|
638
|
+
error." Where no sentence of that shape can be written, no check with a known-bad control can be
|
|
639
|
+
written either, so leave the promise out rather than filing one nothing could ever fail.
|
|
640
|
+
- One leastSure sentence, optional, naming the single thing you are least sure of and what you did
|
|
641
|
+
about it. The person reads that instead of the number.
|
|
445
642
|
|
|
446
643
|
Write them all into one file, an object with a promises array, each entry shaped exactly as one
|
|
447
644
|
proposal file:
|
|
448
645
|
|
|
449
|
-
{"promises": [{"explicitIntent": "...", "teachBack": {"confidence": 0.9, "meaning": {"title": "..."}}}]}
|
|
646
|
+
{"promises": [{"explicitIntent": "...", "teachBack": {"confidence": 0.9, "wrongOutcome": "...", "meaning": {"title": "..."}}}]}
|
|
450
647
|
|
|
451
648
|
An entry carries explicitIntent and teachBack, and inside teachBack only meaning, confidence,
|
|
452
|
-
unresolvedQuestions, and proposedOwnerId. Anything else is refused on this
|
|
453
|
-
leaves it, and one bad entry means not one of them is sent.
|
|
649
|
+
wrongOutcome, leastSure, unresolvedQuestions, and proposedOwnerId. Anything else is refused on this
|
|
650
|
+
machine before the file leaves it, and one bad entry means not one of them is sent.
|
|
454
651
|
|
|
455
652
|
Every promise in the file is proposed as owned by whoever ran setup, and waits for that person. The
|
|
456
653
|
command prints one link that opens all of them at once. Give the person that link and ask them to
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type StdioMcpEntry } from "./mcp-config.js";
|
|
2
|
+
/** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
|
|
3
|
+
export declare const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
|
|
4
|
+
/**
|
|
5
|
+
* Where that file lives, or why this machine has no such place.
|
|
6
|
+
*
|
|
7
|
+
* Two platforms are documented and both are handled: macOS keeps it under
|
|
8
|
+
* `Library/Application Support/Claude`, Windows under `%APPDATA%\Claude`.
|
|
9
|
+
* Claude desktop is published for those two and no others, so every other
|
|
10
|
+
* platform is told so by name rather than written to on a guess. A person
|
|
11
|
+
* running an unofficial build, or a Windows install whose packaging redirects
|
|
12
|
+
* that directory, points `BALLADEER_CLAUDE_DESKTOP_CONFIG` at the file itself
|
|
13
|
+
* and this writes exactly there.
|
|
14
|
+
*/
|
|
15
|
+
export type DesktopLocation = Readonly<{
|
|
16
|
+
kind: "path";
|
|
17
|
+
path: string;
|
|
18
|
+
directory: string;
|
|
19
|
+
}> | Readonly<{
|
|
20
|
+
kind: "unsupported";
|
|
21
|
+
platform: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function desktopConfigLocation(environment?: NodeJS.ProcessEnv, platform?: string): DesktopLocation;
|
|
25
|
+
/**
|
|
26
|
+
* One key per repository, named after the repository.
|
|
27
|
+
*
|
|
28
|
+
* The desktop app has no notion of a project: every server in that file is
|
|
29
|
+
* loaded into every chat. A person who plans work in two repositories therefore
|
|
30
|
+
* has two of our servers running at once, and the only thing that tells them
|
|
31
|
+
* apart in the app's own connector list is the key. `balladeer-owner-name` is
|
|
32
|
+
* that name. A directory whose GitHub origin this command could not read has no
|
|
33
|
+
* name to use, so the key falls back to the repository's own id, which is unique
|
|
34
|
+
* by construction and still tells two entries apart.
|
|
35
|
+
*/
|
|
36
|
+
export declare function desktopServerKey(repositoryName: string, repositoryId: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* The entry the desktop app can actually start, which is not the one a coding
|
|
39
|
+
* agent gets.
|
|
40
|
+
*
|
|
41
|
+
* Claude desktop launches a stdio server from the application rather than from a
|
|
42
|
+
* login shell, with a minimal PATH carrying none of the places a developer's
|
|
43
|
+
* node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
|
|
44
|
+
* to nothing there, and what a person gets instead of a server is a line in a
|
|
45
|
+
* log file nobody opens. This names the interpreter running setup by its
|
|
46
|
+
* absolute path and this command's own entry point by its absolute path, so the
|
|
47
|
+
* entry needs no PATH at all.
|
|
48
|
+
*
|
|
49
|
+
* The environment is written for the same reason. The forwarder reads the bearer
|
|
50
|
+
* out of this machine's credential store, and where that store is depends on a
|
|
51
|
+
* variable the desktop app never inherits, so the resolved directory goes into
|
|
52
|
+
* the entry rather than being left to a shell that is not there. It is a path
|
|
53
|
+
* and never a secret: the credential itself stays in the store, mode 600.
|
|
54
|
+
*/
|
|
55
|
+
export declare function desktopStdioEntry(repositoryId: string, environment?: NodeJS.ProcessEnv, controlPlane?: string, nodePath?: string, entryPath?: string): StdioMcpEntry;
|
|
56
|
+
export type DesktopMergeResult = Readonly<{
|
|
57
|
+
kind: "written";
|
|
58
|
+
changed: boolean;
|
|
59
|
+
path: string;
|
|
60
|
+
key: string;
|
|
61
|
+
}> | Readonly<{
|
|
62
|
+
kind: "absent";
|
|
63
|
+
directory: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
}> | Readonly<{
|
|
66
|
+
kind: "refused";
|
|
67
|
+
reason: string;
|
|
68
|
+
block: string;
|
|
69
|
+
}>;
|
|
70
|
+
export declare function desktopBlock(key: string, entry: StdioMcpEntry): string;
|
|
71
|
+
/**
|
|
72
|
+
* Merges our entry into the desktop app's configuration, or refuses.
|
|
73
|
+
*
|
|
74
|
+
* Everything already in that file stays in it. Other people's servers are other
|
|
75
|
+
* people's servers, and so is every top-level key beside `mcpServers`, which is
|
|
76
|
+
* where the app keeps settings this command knows nothing about. An unparseable
|
|
77
|
+
* file is never overwritten, and a key of ours holding somebody else's server is
|
|
78
|
+
* never replaced: the block is printed and a person decides.
|
|
79
|
+
*
|
|
80
|
+
* The key is found by what the entry says rather than by what it is called. An
|
|
81
|
+
* entry of ours already bound to this repository is rewritten where it sits,
|
|
82
|
+
* whatever it was named, so a repository renamed on GitHub does not quietly
|
|
83
|
+
* acquire a second server that starts alongside the first.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mergeDesktopConfig(location: DesktopLocation, key: string, entry: StdioMcpEntry, repositoryId: string, controlPlane: string): DesktopMergeResult;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { entryRepositoryId, isOurEntry, writeJsonAtomically, } from "./mcp-config.js";
|
|
4
|
+
import { checkoutEntryPath } from "./release.js";
|
|
5
|
+
import { configHome } from "./store.js";
|
|
6
|
+
import { DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
7
|
+
/** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
|
|
8
|
+
export const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
|
|
9
|
+
export function desktopConfigLocation(environment = process.env, platform = process.platform) {
|
|
10
|
+
const override = environment.BALLADEER_CLAUDE_DESKTOP_CONFIG?.trim();
|
|
11
|
+
if (override !== undefined && override.length > 0) {
|
|
12
|
+
// An override that is not absolute would be resolved against whatever
|
|
13
|
+
// directory this command happens to have been started in, which is never
|
|
14
|
+
// the directory the person meant.
|
|
15
|
+
return isAbsolute(override)
|
|
16
|
+
? { kind: "path", path: override, directory: parentOf(override) }
|
|
17
|
+
: {
|
|
18
|
+
kind: "unsupported",
|
|
19
|
+
platform,
|
|
20
|
+
reason: `BALLADEER_CLAUDE_DESKTOP_CONFIG is set to ${override}, which is not an absolute path, so I did not guess what it meant.`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const home = homeOf(environment);
|
|
24
|
+
if (platform === "darwin") {
|
|
25
|
+
if (home === undefined)
|
|
26
|
+
return noHome(platform);
|
|
27
|
+
const directory = join(home, "Library", "Application Support", "Claude");
|
|
28
|
+
return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
|
|
29
|
+
}
|
|
30
|
+
if (platform === "win32") {
|
|
31
|
+
const appData = environment.APPDATA?.trim();
|
|
32
|
+
const roaming = appData !== undefined && appData.length > 0
|
|
33
|
+
? appData
|
|
34
|
+
: home === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: join(home, "AppData", "Roaming");
|
|
37
|
+
if (roaming === undefined)
|
|
38
|
+
return noHome(platform);
|
|
39
|
+
const directory = join(roaming, "Claude");
|
|
40
|
+
return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "unsupported",
|
|
44
|
+
platform,
|
|
45
|
+
reason: `Claude desktop is published for macOS and Windows, and this machine reports ${platform}, so there is no ${DESKTOP_CONFIG_FILE} here to write. If you run an unofficial build, set BALLADEER_CLAUDE_DESKTOP_CONFIG to the absolute path of its ${DESKTOP_CONFIG_FILE} and run this again.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The home directory this environment names, and never the one the operating
|
|
50
|
+
* system would name instead.
|
|
51
|
+
*
|
|
52
|
+
* Asking the OS whose home this is would be a second source, and it answers for
|
|
53
|
+
* the account the process runs under rather than for the environment it was
|
|
54
|
+
* handed. A command given a bounded environment on purpose, which is what the
|
|
55
|
+
* tests hand it and what a service account hands it, would reach past that
|
|
56
|
+
* environment and write into a real person's chat client. So an environment that
|
|
57
|
+
* names no home is an answer: this run cannot tell where Claude desktop's
|
|
58
|
+
* configuration is, and it says so rather than guessing.
|
|
59
|
+
*/
|
|
60
|
+
function homeOf(environment) {
|
|
61
|
+
for (const value of [environment.HOME, environment.USERPROFILE]) {
|
|
62
|
+
const trimmed = value?.trim();
|
|
63
|
+
if (trimmed !== undefined && trimmed.length > 0)
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function noHome(platform) {
|
|
69
|
+
return {
|
|
70
|
+
kind: "unsupported",
|
|
71
|
+
platform,
|
|
72
|
+
reason: `This process was given no HOME, so I could not tell where Claude desktop keeps ${DESKTOP_CONFIG_FILE} and did not guess. Set BALLADEER_CLAUDE_DESKTOP_CONFIG to its absolute path, or run this from a shell that sets HOME.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function parentOf(path) {
|
|
76
|
+
const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
77
|
+
return at <= 0 ? path : path.slice(0, at);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One key per repository, named after the repository.
|
|
81
|
+
*
|
|
82
|
+
* The desktop app has no notion of a project: every server in that file is
|
|
83
|
+
* loaded into every chat. A person who plans work in two repositories therefore
|
|
84
|
+
* has two of our servers running at once, and the only thing that tells them
|
|
85
|
+
* apart in the app's own connector list is the key. `balladeer-owner-name` is
|
|
86
|
+
* that name. A directory whose GitHub origin this command could not read has no
|
|
87
|
+
* name to use, so the key falls back to the repository's own id, which is unique
|
|
88
|
+
* by construction and still tells two entries apart.
|
|
89
|
+
*/
|
|
90
|
+
export function desktopServerKey(repositoryName, repositoryId) {
|
|
91
|
+
const cleaned = repositoryName
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "");
|
|
95
|
+
const usable = cleaned.length > 0 && cleaned !== "unknown-unknown" ? cleaned : repositoryId.toLowerCase();
|
|
96
|
+
return `balladeer-${usable}`.slice(0, 100).replace(/-+$/, "");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The entry the desktop app can actually start, which is not the one a coding
|
|
100
|
+
* agent gets.
|
|
101
|
+
*
|
|
102
|
+
* Claude desktop launches a stdio server from the application rather than from a
|
|
103
|
+
* login shell, with a minimal PATH carrying none of the places a developer's
|
|
104
|
+
* node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
|
|
105
|
+
* to nothing there, and what a person gets instead of a server is a line in a
|
|
106
|
+
* log file nobody opens. This names the interpreter running setup by its
|
|
107
|
+
* absolute path and this command's own entry point by its absolute path, so the
|
|
108
|
+
* entry needs no PATH at all.
|
|
109
|
+
*
|
|
110
|
+
* The environment is written for the same reason. The forwarder reads the bearer
|
|
111
|
+
* out of this machine's credential store, and where that store is depends on a
|
|
112
|
+
* variable the desktop app never inherits, so the resolved directory goes into
|
|
113
|
+
* the entry rather than being left to a shell that is not there. It is a path
|
|
114
|
+
* and never a secret: the credential itself stays in the store, mode 600.
|
|
115
|
+
*/
|
|
116
|
+
export function desktopStdioEntry(repositoryId, environment = process.env, controlPlane, nodePath = process.execPath, entryPath = checkoutEntryPath()) {
|
|
117
|
+
const env = { BALLADEER_CONFIG_HOME: configHome(environment) };
|
|
118
|
+
// Only when it is not the default. A deployment nobody named is the one the
|
|
119
|
+
// forwarder already reaches on its own, and writing it down would pin an
|
|
120
|
+
// address that a later release moves.
|
|
121
|
+
if (controlPlane !== undefined && controlPlane !== DEFAULT_CONTROL_PLANE) {
|
|
122
|
+
env.BALLADEER_CONTROL_PLANE = controlPlane;
|
|
123
|
+
}
|
|
124
|
+
return { command: nodePath, args: [entryPath, "mcp", "--repository", repositoryId], env };
|
|
125
|
+
}
|
|
126
|
+
export function desktopBlock(key, entry) {
|
|
127
|
+
return JSON.stringify({ mcpServers: { [key]: entry } }, null, 2);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Merges our entry into the desktop app's configuration, or refuses.
|
|
131
|
+
*
|
|
132
|
+
* Everything already in that file stays in it. Other people's servers are other
|
|
133
|
+
* people's servers, and so is every top-level key beside `mcpServers`, which is
|
|
134
|
+
* where the app keeps settings this command knows nothing about. An unparseable
|
|
135
|
+
* file is never overwritten, and a key of ours holding somebody else's server is
|
|
136
|
+
* never replaced: the block is printed and a person decides.
|
|
137
|
+
*
|
|
138
|
+
* The key is found by what the entry says rather than by what it is called. An
|
|
139
|
+
* entry of ours already bound to this repository is rewritten where it sits,
|
|
140
|
+
* whatever it was named, so a repository renamed on GitHub does not quietly
|
|
141
|
+
* acquire a second server that starts alongside the first.
|
|
142
|
+
*/
|
|
143
|
+
export function mergeDesktopConfig(location, key, entry, repositoryId, controlPlane) {
|
|
144
|
+
const block = desktopBlock(key, entry);
|
|
145
|
+
if (location.kind === "unsupported") {
|
|
146
|
+
return { kind: "refused", reason: location.reason, block };
|
|
147
|
+
}
|
|
148
|
+
// The app creates its own directory the first time it runs. Creating one it
|
|
149
|
+
// never made would leave a configuration file behind for an application that
|
|
150
|
+
// is not installed, and the person would read "connected" about a chat client
|
|
151
|
+
// they do not have.
|
|
152
|
+
if (!existsSync(location.directory)) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "absent",
|
|
155
|
+
directory: location.directory,
|
|
156
|
+
reason: `Claude desktop was not found on this machine: ${location.directory} does not exist, so I wrote no ${DESKTOP_CONFIG_FILE}. Install Claude desktop and open it once, then run this again.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
let existing = "";
|
|
160
|
+
try {
|
|
161
|
+
existing = readFileSync(location.path, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
writeJsonAtomically(location.path, `${block}\n`);
|
|
165
|
+
return { kind: "written", changed: true, path: location.path, key };
|
|
166
|
+
}
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
const value = JSON.parse(existing);
|
|
170
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
171
|
+
throw new Error("not an object");
|
|
172
|
+
}
|
|
173
|
+
parsed = value;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {
|
|
177
|
+
kind: "refused",
|
|
178
|
+
reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} could not be parsed; I did not change it.`,
|
|
179
|
+
block,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const raw = parsed.mcpServers;
|
|
183
|
+
if (raw !== undefined && (raw === null || typeof raw !== "object" || Array.isArray(raw))) {
|
|
184
|
+
return {
|
|
185
|
+
kind: "refused",
|
|
186
|
+
reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} has an mcpServers value that is not a set of servers; I did not change it.`,
|
|
187
|
+
block,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const servers = raw === undefined ? {} : { ...raw };
|
|
191
|
+
const target = ourKeyFor(servers, repositoryId, controlPlane) ?? key;
|
|
192
|
+
const current = servers[target];
|
|
193
|
+
if (current !== undefined && !isOurEntry(current, controlPlane)) {
|
|
194
|
+
return {
|
|
195
|
+
kind: "refused",
|
|
196
|
+
reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} already has an entry named ${target} that is not this workspace's server; I did not change it.`,
|
|
197
|
+
block: desktopBlock(target, entry),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
servers[target] = entry;
|
|
201
|
+
const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
|
|
202
|
+
if (next === existing) {
|
|
203
|
+
return { kind: "written", changed: false, path: location.path, key: target };
|
|
204
|
+
}
|
|
205
|
+
writeJsonAtomically(location.path, next);
|
|
206
|
+
return { kind: "written", changed: true, path: location.path, key: target };
|
|
207
|
+
}
|
|
208
|
+
/** The key already carrying our server for this repository, whatever it is called. */
|
|
209
|
+
function ourKeyFor(servers, repositoryId, controlPlane) {
|
|
210
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
211
|
+
if (!isOurEntry(value, controlPlane))
|
|
212
|
+
continue;
|
|
213
|
+
if (entryRepositoryId(value) === repositoryId)
|
|
214
|
+
return name;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|