balladeer 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -18
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +148 -9
- package/dist/client.d.ts +23 -1
- package/dist/client.js +29 -1
- package/dist/commands/affected.js +2 -1
- package/dist/commands/discover.js +12 -4
- 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 +217 -0
- package/dist/commands/propose.d.ts +10 -0
- package/dist/commands/propose.js +25 -3
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +23 -0
- package/dist/commands/setup.js +157 -28
- package/dist/commands/status.d.ts +16 -0
- package/dist/commands/status.js +67 -7
- 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 +79 -3
- package/dist/copy.js +187 -5
- 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 +10 -0
- package/dist/store.js +15 -3
- package/dist/wire.d.ts +88 -2
- package/dist/wire.js +1 -1
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -2,13 +2,16 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
5
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, request, } from "../client.js";
|
|
6
6
|
import { writeConventions } from "../conventions.js";
|
|
7
|
-
import { DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
|
|
8
|
-
import { readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
|
|
7
|
+
import { APPROVE_IN_THE_RIGHT_WORKSPACE, DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
|
|
8
|
+
import { ghLogin, readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
|
|
9
9
|
import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBranch, } from "../git.js";
|
|
10
10
|
import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
|
|
11
|
+
import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
|
|
11
12
|
import { selectAgent } from "../agent.js";
|
|
13
|
+
import { findLegacyInstall, legacyMessage } from "../legacy.js";
|
|
14
|
+
import { formatInstant } from "../local-time.js";
|
|
12
15
|
import { CONVENTIONS_VERSION } from "../conventions.js";
|
|
13
16
|
import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
|
|
14
17
|
import { hostHint, repositoryHint } from "../repository.js";
|
|
@@ -126,7 +129,7 @@ function pairedLines(session) {
|
|
|
126
129
|
` This session may: ${session.scopeMeanings.join(", ")}.`,
|
|
127
130
|
...narrowed,
|
|
128
131
|
` ${REFUSAL_SENTENCE}`,
|
|
129
|
-
` It expires at ${session.expiresAt}. Revoke it any time under Connected sessions in Balladeer.`,
|
|
132
|
+
` It expires at ${formatInstant(session.expiresAt)}. Revoke it any time under Connected sessions in Balladeer.`,
|
|
130
133
|
];
|
|
131
134
|
}
|
|
132
135
|
/**
|
|
@@ -147,8 +150,14 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
|
|
|
147
150
|
...opening,
|
|
148
151
|
` Open: ${approvalUri}`,
|
|
149
152
|
` The page will show this code: ${pending.userCode}`,
|
|
153
|
+
// A person who belongs to two workspaces approves into whichever one their
|
|
154
|
+
// browser is signed into, and twice that was not the one they meant: the
|
|
155
|
+
// repository was enrolled in the wrong workspace and an agent was connected
|
|
156
|
+
// there. The page now names the workspace and offers the switch, and this
|
|
157
|
+
// line is what makes somebody look at it.
|
|
158
|
+
` ${APPROVE_IN_THE_RIGHT_WORKSPACE}`,
|
|
150
159
|
` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
|
|
151
|
-
` This pairing expires at ${pending.expiresAt}.`,
|
|
160
|
+
` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
|
|
152
161
|
" Approve in the browser, then run this command again to finish.",
|
|
153
162
|
"",
|
|
154
163
|
// Printed on the pairing step and nowhere else, because this is the one
|
|
@@ -244,6 +253,16 @@ function namedRepositories(options) {
|
|
|
244
253
|
* a command that blocks for minutes and the pairing would be lost with it.
|
|
245
254
|
*/
|
|
246
255
|
export async function runSetup(options) {
|
|
256
|
+
// First, before the banner and before either path writes a byte: a machine
|
|
257
|
+
// that still carries the July client gets one instruction and this run stops.
|
|
258
|
+
// Refresh is inside the check rather than outside it, because a repair writes
|
|
259
|
+
// the same two files a first setup writes.
|
|
260
|
+
const legacy = options.force === true
|
|
261
|
+
? undefined
|
|
262
|
+
: findLegacyInstall(options.environment, options.controlPlane);
|
|
263
|
+
if (legacy !== undefined) {
|
|
264
|
+
return fail(options, "legacy_balladeer_present", legacyMessage(legacy), 6);
|
|
265
|
+
}
|
|
247
266
|
if (options.refresh === true)
|
|
248
267
|
return runRefresh(options);
|
|
249
268
|
const { controlPlane } = options;
|
|
@@ -365,7 +384,7 @@ export async function runSetup(options) {
|
|
|
365
384
|
return reportStartFailure(options, error);
|
|
366
385
|
}
|
|
367
386
|
if (lapsed !== undefined) {
|
|
368
|
-
say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${lapsed.expiresAt}; here is a new one.`);
|
|
387
|
+
say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${formatInstant(lapsed.expiresAt)}; here is a new one.`);
|
|
369
388
|
emit(options, {
|
|
370
389
|
step: "pair",
|
|
371
390
|
status: "expired",
|
|
@@ -488,7 +507,7 @@ function terminalRefusal(pending, code) {
|
|
|
488
507
|
const again = "Run this command again to start a new one.";
|
|
489
508
|
if (code === "pairing_expired") {
|
|
490
509
|
return {
|
|
491
|
-
message: `Pairing ${pending.userCode} expired at ${pending.expiresAt}. ${again}`,
|
|
510
|
+
message: `Pairing ${pending.userCode} expired at ${formatInstant(pending.expiresAt)}. ${again}`,
|
|
492
511
|
status: "expired",
|
|
493
512
|
};
|
|
494
513
|
}
|
|
@@ -519,7 +538,7 @@ async function waitForApproval(options, credentials, pending, pollIntervalSecond
|
|
|
519
538
|
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
520
539
|
const now = options.now ?? (() => new Date());
|
|
521
540
|
const deadline = now().getTime() + MAX_WAIT_MS;
|
|
522
|
-
say(options, ` Waiting up to ${MAX_WAIT_MS / 60_000} minutes for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${pending.expiresAt}.`);
|
|
541
|
+
say(options, ` Waiting up to ${MAX_WAIT_MS / 60_000} minutes for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${formatInstant(pending.expiresAt)}.`);
|
|
523
542
|
let interval = pollIntervalSeconds;
|
|
524
543
|
while (now().getTime() < deadline) {
|
|
525
544
|
await sleep(Math.max(interval, 2) * 1000);
|
|
@@ -566,6 +585,7 @@ async function runRemainingSteps(options, credentials, session) {
|
|
|
566
585
|
return transportFailure(options, error.message);
|
|
567
586
|
return fail(options, "setup_state_unreadable", `Balladeer could not report this workspace's setup: ${refusalSentence(error)}. Nothing was changed.`, 5);
|
|
568
587
|
}
|
|
588
|
+
await claimGithubLogin(options, session);
|
|
569
589
|
const chosen = chooseRepositories(namedRepositories(options), options.cwd);
|
|
570
590
|
if (chosen.kind === "mismatch") {
|
|
571
591
|
return fail(options, "repository_mismatch", chosen.message, 4);
|
|
@@ -593,6 +613,44 @@ async function runRemainingSteps(options, credentials, session) {
|
|
|
593
613
|
printReceipts(options, session, finalState ?? state, view);
|
|
594
614
|
return 0;
|
|
595
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Hands over the GitHub login this machine is signed in as, so a run this
|
|
618
|
+
* person starts reads as their name rather than as a handle.
|
|
619
|
+
*
|
|
620
|
+
* It sends a login and never a token. `gh` is the person's own tool, already
|
|
621
|
+
* signed in on their own machine, and the one thing read out of it here is the
|
|
622
|
+
* name of the account. Balladeer stores that as a claim: nothing checks it,
|
|
623
|
+
* nothing is granted by it, and the settings page lets the person take it back.
|
|
624
|
+
*
|
|
625
|
+
* Every failure is silent to the run and visible in the receipt. No `gh`, no
|
|
626
|
+
* login, an older control plane that has never heard of this call: setup goes
|
|
627
|
+
* on. A person cannot be stopped from enrolling a repository because a display
|
|
628
|
+
* detail could not be recorded.
|
|
629
|
+
*/
|
|
630
|
+
async function claimGithubLogin(options, session) {
|
|
631
|
+
const login = await ghLogin();
|
|
632
|
+
if (login === undefined) {
|
|
633
|
+
emit(options, { step: "github_login", status: "unavailable" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const answer = await request(options.controlPlane, {
|
|
638
|
+
method: "POST",
|
|
639
|
+
path: "/api/setup/v1/github-login",
|
|
640
|
+
bearer: session.token,
|
|
641
|
+
body: { login },
|
|
642
|
+
});
|
|
643
|
+
if (answer.claimedLogin === null) {
|
|
644
|
+
emit(options, { step: "github_login", status: "refused" });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
say(options, `Signed in to GitHub as ${answer.claimedLogin}. Runs you start will carry your name on the promises they protect. Balladeer holds no GitHub token and has not checked this; clear it any time at ${options.controlPlane}/settings#members.\n`);
|
|
648
|
+
emit(options, { step: "github_login", status: "claimed", login: answer.claimedLogin });
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
emit(options, { step: "github_login", status: "refused" });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
596
654
|
async function readState(options, session) {
|
|
597
655
|
try {
|
|
598
656
|
return await request(options.controlPlane, {
|
|
@@ -758,19 +816,67 @@ function blockedRepository(options, role, name, reason, next, publicFacts) {
|
|
|
758
816
|
emit(options, { step: "repository", status: "blocked", repository: name, reason });
|
|
759
817
|
return { view: undefined };
|
|
760
818
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
819
|
+
const NO_DESKTOP = { lines: [], json: undefined };
|
|
820
|
+
function connectClaudeDesktop(options, repositoryId, repositoryName) {
|
|
821
|
+
if (options.claudeDesktop === false)
|
|
822
|
+
return NO_DESKTOP;
|
|
823
|
+
// Asked for by name, or taken by default. The difference is only whether a
|
|
824
|
+
// machine with no Claude desktop on it hears about it: a person who typed the
|
|
825
|
+
// flag is owed the answer, and a person who typed nothing is not owed a line
|
|
826
|
+
// about an application they never mentioned.
|
|
827
|
+
const asked = options.claudeDesktop === true;
|
|
828
|
+
const location = desktopConfigLocation(options.environment);
|
|
829
|
+
if (location.kind === "unsupported") {
|
|
830
|
+
return asked
|
|
831
|
+
? {
|
|
832
|
+
lines: [` ${location.reason}`],
|
|
833
|
+
json: { status: "unsupported", reason: location.reason },
|
|
834
|
+
}
|
|
835
|
+
: NO_DESKTOP;
|
|
836
|
+
}
|
|
837
|
+
const key = desktopServerKey(repositoryName, repositoryId);
|
|
838
|
+
const entry = desktopStdioEntry(repositoryId, options.environment, options.controlPlane);
|
|
839
|
+
const merged = mergeDesktopConfig(location, key, entry, repositoryId, options.controlPlane);
|
|
840
|
+
if (merged.kind === "absent") {
|
|
841
|
+
return asked
|
|
842
|
+
? { lines: [` ${merged.reason}`], json: { status: "absent", reason: merged.reason } }
|
|
843
|
+
: NO_DESKTOP;
|
|
844
|
+
}
|
|
845
|
+
if (merged.kind === "refused") {
|
|
846
|
+
return {
|
|
847
|
+
lines: [
|
|
848
|
+
` ${merged.reason}`,
|
|
849
|
+
" Merge this block into it yourself:",
|
|
850
|
+
...merged.block.split("\n").map((line) => ` ${line}`),
|
|
851
|
+
],
|
|
852
|
+
json: { status: "refused", reason: merged.reason },
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
// `unknown/unknown` is what the origin parse answers when it read nothing, and
|
|
856
|
+
// reading it back at somebody as though it were a repository name is worse
|
|
857
|
+
// than not naming the repository at all.
|
|
858
|
+
const named = repositoryName === "unknown/unknown" || repositoryName.length === 0
|
|
859
|
+
? "this repository"
|
|
860
|
+
: repositoryName;
|
|
861
|
+
return {
|
|
862
|
+
lines: merged.changed
|
|
863
|
+
? [
|
|
864
|
+
` Also connected Claude desktop chat for ${named}, as ${merged.key} in ${merged.path}. Every other server in that file is untouched and this one carries no credential.`,
|
|
865
|
+
// Said only where something changed. Telling somebody to restart an
|
|
866
|
+
// application over a file nothing wrote to is noise, and noise is how
|
|
867
|
+
// the line that does matter stops being read.
|
|
868
|
+
` Quit Claude desktop completely and open it again: it reads ${DESKTOP_CONFIG_FILE} at startup, so an app that is already running will not see this.`,
|
|
869
|
+
]
|
|
870
|
+
: [
|
|
871
|
+
` Claude desktop chat already runs the current command for ${named} as ${merged.key}; I left ${merged.path} alone.`,
|
|
872
|
+
],
|
|
873
|
+
json: {
|
|
874
|
+
status: merged.changed ? "connected" : "current",
|
|
875
|
+
key: merged.key,
|
|
876
|
+
path: merged.path,
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
}
|
|
774
880
|
function hostNextSteps() {
|
|
775
881
|
return [
|
|
776
882
|
` 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.`,
|
|
@@ -938,6 +1044,13 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
938
1044
|
}
|
|
939
1045
|
}
|
|
940
1046
|
}
|
|
1047
|
+
// The chat client, connected from the same credential this step just stored.
|
|
1048
|
+
// It is not conditional on a git work tree, because the desktop app has no
|
|
1049
|
+
// notion of a project and its file lives in this person's home directory
|
|
1050
|
+
// rather than in the repository.
|
|
1051
|
+
const desktop = connectClaudeDesktop(options, packet.repositoryId, name);
|
|
1052
|
+
for (const line of desktop.lines)
|
|
1053
|
+
say(options, line);
|
|
941
1054
|
// Said on every ending of this step, including the ones where no file was
|
|
942
1055
|
// written: whoever is reading has to configure that host by hand, and they
|
|
943
1056
|
// need the same two facts about when a host picks an entry up.
|
|
@@ -959,6 +1072,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
959
1072
|
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
960
1073
|
...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
|
|
961
1074
|
...(conventions?.kind === "refused" ? { conventions: conventions.reason } : {}),
|
|
1075
|
+
...(desktop.json === undefined ? {} : { claudeDesktop: desktop.json }),
|
|
962
1076
|
});
|
|
963
1077
|
return undefined;
|
|
964
1078
|
}
|
|
@@ -986,7 +1100,7 @@ async function stepCi(options, session, view, name) {
|
|
|
986
1100
|
if (!upgrading && view.ciConfigured) {
|
|
987
1101
|
// "Connected" appears on this branch and nowhere else in the command: it is
|
|
988
1102
|
// the one state Balladeer observed rather than recorded.
|
|
989
|
-
say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt
|
|
1103
|
+
say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt === null ? "an earlier run" : formatInstant(view.ciFirstValidatedAt)}`);
|
|
990
1104
|
emit(options, {
|
|
991
1105
|
step: "ci",
|
|
992
1106
|
status: "connected",
|
|
@@ -1241,7 +1355,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1241
1355
|
return;
|
|
1242
1356
|
}
|
|
1243
1357
|
if (current.latestCandidateId !== null) {
|
|
1244
|
-
const reviewUrl =
|
|
1358
|
+
const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId);
|
|
1245
1359
|
say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
|
|
1246
1360
|
say(options, ` Review it: ${reviewUrl}`);
|
|
1247
1361
|
say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
|
|
@@ -1304,7 +1418,7 @@ function printReceipts(options, session, state, view) {
|
|
|
1304
1418
|
const promiseUrl = promiseId === null ? undefined : `${options.controlPlane}/promises/${promiseId}`;
|
|
1305
1419
|
const reviewUrl = promiseId !== null || candidateId === null
|
|
1306
1420
|
? undefined
|
|
1307
|
-
:
|
|
1421
|
+
: proposalReviewLink(options.controlPlane, candidateId);
|
|
1308
1422
|
const promiseLink = promiseUrl ?? reviewUrl;
|
|
1309
1423
|
const promiseSentence = promiseId !== null
|
|
1310
1424
|
? "Agreed. A named person agreed to this repository's first promise."
|
|
@@ -1402,11 +1516,14 @@ async function runRefresh(options) {
|
|
|
1402
1516
|
}
|
|
1403
1517
|
const existing = currentEntry(readMcpConfig(root));
|
|
1404
1518
|
let stored;
|
|
1519
|
+
let storedName;
|
|
1405
1520
|
try {
|
|
1406
1521
|
const credentials = readCredentials(options.environment);
|
|
1407
1522
|
const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHint(options.cwd));
|
|
1408
|
-
if (selection.kind === "agent")
|
|
1523
|
+
if (selection.kind === "agent") {
|
|
1409
1524
|
stored = selection.agent.repositoryId;
|
|
1525
|
+
storedName = selection.agent.repository;
|
|
1526
|
+
}
|
|
1410
1527
|
}
|
|
1411
1528
|
catch {
|
|
1412
1529
|
// A store this machine cannot read is not a reason to refuse the repair:
|
|
@@ -1433,6 +1550,12 @@ async function runRefresh(options) {
|
|
|
1433
1550
|
say(options, conventions.reason);
|
|
1434
1551
|
return failRefresh(options, "conventions_refused", conventions.reason);
|
|
1435
1552
|
}
|
|
1553
|
+
// The third stale thing, repaired by the same command and for the same
|
|
1554
|
+
// reason. A desktop entry naming an interpreter that a node upgrade moved is
|
|
1555
|
+
// a chat client that silently has no Balladeer in it, and nothing on screen
|
|
1556
|
+
// says so. This rewrites it to the interpreter and entry point running right
|
|
1557
|
+
// now, and leaves every other server in that file alone.
|
|
1558
|
+
const desktop = connectClaudeDesktop(options, repositoryId, storedName ?? repositoryHint(options.cwd));
|
|
1436
1559
|
const files = [];
|
|
1437
1560
|
if (merged.changed)
|
|
1438
1561
|
files.push(MCP_CONFIG_FILE);
|
|
@@ -1446,21 +1569,27 @@ async function runRefresh(options) {
|
|
|
1446
1569
|
? ""
|
|
1447
1570
|
: ` (it was v${conventions.previousVersion})`}. Everything outside the markers is untouched.`
|
|
1448
1571
|
: `${conventions.file} already carries conventions v${conventions.version}; I left it alone.`);
|
|
1449
|
-
|
|
1572
|
+
// Said before the closing line, because it names a file outside the working
|
|
1573
|
+
// tree and the closing line is about what to commit. The desktop entry is
|
|
1574
|
+
// never a change anybody commits: it lives in this person's home directory.
|
|
1575
|
+
for (const line of desktop.lines)
|
|
1576
|
+
say(options, line.replace(/^ {2}/, ""));
|
|
1577
|
+
if (files.length === 0 && desktop.json?.status !== "connected") {
|
|
1450
1578
|
say(options, "Nothing to change: this repository is already on the current form.");
|
|
1451
1579
|
}
|
|
1452
|
-
else {
|
|
1580
|
+
else if (files.length > 0) {
|
|
1453
1581
|
say(options, `Commit ${files.join(" and ")} when you are ready.`);
|
|
1454
1582
|
}
|
|
1455
1583
|
emit(options, {
|
|
1456
1584
|
step: "refresh",
|
|
1457
|
-
status: files.length === 0 ? "current" : "updated",
|
|
1585
|
+
status: files.length === 0 && desktop.json?.status !== "connected" ? "current" : "updated",
|
|
1458
1586
|
repositoryId,
|
|
1459
1587
|
files,
|
|
1460
1588
|
conventionsVersion: conventions.version,
|
|
1461
1589
|
...(conventions.previousVersion === undefined
|
|
1462
1590
|
? {}
|
|
1463
1591
|
: { previousConventionsVersion: conventions.previousVersion }),
|
|
1592
|
+
...(desktop.json === undefined ? {} : { claudeDesktop: desktop.json }),
|
|
1464
1593
|
});
|
|
1465
1594
|
return 0;
|
|
1466
1595
|
}
|
|
@@ -18,6 +18,22 @@ export type StatusOptions = Readonly<{
|
|
|
18
18
|
cwd: string;
|
|
19
19
|
write: (text: string) => void;
|
|
20
20
|
}>;
|
|
21
|
+
/**
|
|
22
|
+
* The two postures in which Balladeer is reporting the behavior as holding.
|
|
23
|
+
*
|
|
24
|
+
* Named as the small set rather than as "everything that is not broken",
|
|
25
|
+
* because the states this command actually meets are mostly neither. A promise
|
|
26
|
+
* whose meaning somebody agreed and nobody has checked, one whose required run
|
|
27
|
+
* never arrived, one whose newest evidence aged out, and one whose seal no
|
|
28
|
+
* longer matches all have no failing run behind them, and a reader who takes
|
|
29
|
+
* the absence of a failing run for a pass has been told the one thing this
|
|
30
|
+
* product refuses to say. The published contract this mirrors is `NOT_HOLDING`
|
|
31
|
+
* in `@balladeer/service` and the posture enum in `@balladeer/contracts`; this
|
|
32
|
+
* command carries its own copy because the published package depends on
|
|
33
|
+
* neither, and `tests/cli/promise-status-posture.test.ts` fails if the two
|
|
34
|
+
* drift apart.
|
|
35
|
+
*/
|
|
36
|
+
export declare const HOLDING_POSTURES: ReadonlySet<string>;
|
|
21
37
|
/**
|
|
22
38
|
* What Balladeer looks like right now, read from the server and never from
|
|
23
39
|
* anything this machine remembers.
|
package/dist/commands/status.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
|
|
2
|
-
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
2
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, request, } from "../client.js";
|
|
3
3
|
import { repositoryRoot } from "../git.js";
|
|
4
4
|
import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
|
|
5
|
+
import { formatInstant } from "../local-time.js";
|
|
5
6
|
import { commandLine } from "../release.js";
|
|
6
7
|
import { repositoryHint } from "../repository.js";
|
|
7
8
|
import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
|
|
@@ -13,6 +14,39 @@ import {} from "../wire.js";
|
|
|
13
14
|
* `session_expired` pairs again and still holds nothing.
|
|
14
15
|
*/
|
|
15
16
|
const NO_CREDENTIAL_HERE = "no_agent_credential_on_this_machine";
|
|
17
|
+
/**
|
|
18
|
+
* The two postures in which Balladeer is reporting the behavior as holding.
|
|
19
|
+
*
|
|
20
|
+
* Named as the small set rather than as "everything that is not broken",
|
|
21
|
+
* because the states this command actually meets are mostly neither. A promise
|
|
22
|
+
* whose meaning somebody agreed and nobody has checked, one whose required run
|
|
23
|
+
* never arrived, one whose newest evidence aged out, and one whose seal no
|
|
24
|
+
* longer matches all have no failing run behind them, and a reader who takes
|
|
25
|
+
* the absence of a failing run for a pass has been told the one thing this
|
|
26
|
+
* product refuses to say. The published contract this mirrors is `NOT_HOLDING`
|
|
27
|
+
* in `@balladeer/service` and the posture enum in `@balladeer/contracts`; this
|
|
28
|
+
* command carries its own copy because the published package depends on
|
|
29
|
+
* neither, and `tests/cli/promise-status-posture.test.ts` fails if the two
|
|
30
|
+
* drift apart.
|
|
31
|
+
*/
|
|
32
|
+
export const HOLDING_POSTURES = new Set(["protected", "qualifying"]);
|
|
33
|
+
/**
|
|
34
|
+
* Why a promise with no failing run behind it is still not holding.
|
|
35
|
+
*
|
|
36
|
+
* Two spellings of the broken seal, deliberately. `seal_broken` is what a read
|
|
37
|
+
* answers with; `custody_invalid` is what a Balladeer running behind this copy
|
|
38
|
+
* of the command still answers with, and a person talking to one of those is
|
|
39
|
+
* owed the sentence rather than the fallback.
|
|
40
|
+
*/
|
|
41
|
+
const POSTURE_REASONS = {
|
|
42
|
+
unknown: "No result could be trusted to say whether this holds.",
|
|
43
|
+
stale: "The bound verifier no longer matches the agreed meaning.",
|
|
44
|
+
seal_broken: "The last result could not be tied to the workflow this repository enrolled.",
|
|
45
|
+
custody_invalid: "The last result could not be tied to the workflow this repository enrolled.",
|
|
46
|
+
retired: "This promise was retired, and nothing checks it.",
|
|
47
|
+
};
|
|
48
|
+
/** What a posture this copy does not recognise says, rather than a green word. */
|
|
49
|
+
const UNREPORTED_POSTURE = "Balladeer is not reporting this behavior as holding, and this copy of the command does not recognise the state it is in.";
|
|
16
50
|
function noSessionSentence(controlPlane) {
|
|
17
51
|
return `Every repository in the workspace is read over a setup session, and none is stored for ${controlPlane}. Run \`${commandLine(null, "setup")}\` to read the whole workspace.`;
|
|
18
52
|
}
|
|
@@ -252,7 +286,7 @@ async function reportOnePromise(options, promiseId, agent, emit, say) {
|
|
|
252
286
|
const structured = call.structured;
|
|
253
287
|
if (structured.found === false) {
|
|
254
288
|
const message = structuredString(call.structured, "reason") ??
|
|
255
|
-
`No
|
|
289
|
+
`No promise with the id ${promiseId} has meaning a named person has agreed in this repository, so there is nothing to read. Check the id with the person who gave it to you, or read what this repository has promised with list_promises.`;
|
|
256
290
|
emit({ step: "promise", status: "unreadable", promiseId, message });
|
|
257
291
|
say(message);
|
|
258
292
|
return 4;
|
|
@@ -270,11 +304,35 @@ async function reportOnePromise(options, promiseId, agent, emit, say) {
|
|
|
270
304
|
...(promiseUrl === undefined ? {} : { promiseUrl }),
|
|
271
305
|
};
|
|
272
306
|
if (threat === undefined) {
|
|
273
|
-
|
|
307
|
+
// Holding is read off the posture, never off the absence of a failing run.
|
|
308
|
+
// A promise nobody has built a check for has no failing run, and so does a
|
|
309
|
+
// promise whose required run never arrived, and answering "holding" to
|
|
310
|
+
// either one is the thing this product exists to refuse: agreed-but-
|
|
311
|
+
// unprotected meaning is context, and evidence that could not decide is
|
|
312
|
+
// Unknown. Both are green words away from anything green.
|
|
313
|
+
const holding = posture !== undefined && HOLDING_POSTURES.has(posture);
|
|
314
|
+
emit({ step: "promise", status: holding ? "holding" : "not_holding", ...named });
|
|
274
315
|
say(`${title ?? promiseId} (${promiseId})`);
|
|
275
316
|
if (posture !== undefined)
|
|
276
317
|
say(` Balladeer reports this promise as ${posture}.`);
|
|
277
|
-
|
|
318
|
+
// Agreed and nothing checking it is not a fault, so it is not reported as
|
|
319
|
+
// one. It is the one state where there is something to build, and the line
|
|
320
|
+
// that says what to run is the difference between a person reading
|
|
321
|
+
// "unqualified" and a person finishing the job.
|
|
322
|
+
if (posture === "unqualified") {
|
|
323
|
+
say(" Nobody has built a check for this promise yet, so nothing is proving it.");
|
|
324
|
+
say(` Prepare its qualification setup with: ${commandLine(null, "prepare")} ${promiseId}`);
|
|
325
|
+
}
|
|
326
|
+
else if (holding) {
|
|
327
|
+
say(" Nothing is reported broken here, so there is nothing to repair.");
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// No run to read back, so none of the repair facts exist. What the person
|
|
331
|
+
// gets instead is why this is not holding and the page that carries the
|
|
332
|
+
// rest, rather than a sentence telling them nothing is wrong.
|
|
333
|
+
say(` ${POSTURE_REASONS[posture ?? ""] ?? UNREPORTED_POSTURE}`);
|
|
334
|
+
say(" No run is recorded against it here, so there is nothing to read back yet.");
|
|
335
|
+
}
|
|
278
336
|
if (promiseUrl !== undefined)
|
|
279
337
|
say(` Promise page: ${promiseUrl}`);
|
|
280
338
|
return 0;
|
|
@@ -310,7 +368,7 @@ async function reportOnePromise(options, promiseId, agent, emit, say) {
|
|
|
310
368
|
if (caseNote !== undefined)
|
|
311
369
|
say(` ${caseNote}`);
|
|
312
370
|
if (observedAt !== undefined)
|
|
313
|
-
say(` Observed: ${observedAt}`);
|
|
371
|
+
say(` Observed: ${formatInstant(observedAt)}`);
|
|
314
372
|
if (ownerName !== undefined)
|
|
315
373
|
say(` Owner: ${ownerName}`);
|
|
316
374
|
if (promiseUrl !== undefined)
|
|
@@ -456,7 +514,9 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
|
|
|
456
514
|
say(` Default branch: ${repository.defaultBranch}`);
|
|
457
515
|
say(` Agent: ${repository.agentConfigured ? "connected" : "not connected"}`);
|
|
458
516
|
say(` CI: ${repository.ciConfigured
|
|
459
|
-
? `connected, ${repository.ciValidatedRunCount} authenticated run${repository.ciValidatedRunCount === 1 ? "" : "s"}, first observed ${repository.ciFirstValidatedAt
|
|
517
|
+
? `connected, ${repository.ciValidatedRunCount} authenticated run${repository.ciValidatedRunCount === 1 ? "" : "s"}, first observed ${repository.ciFirstValidatedAt === null
|
|
518
|
+
? "at an earlier run"
|
|
519
|
+
: formatInstant(repository.ciFirstValidatedAt)}`
|
|
460
520
|
: repository.ciIdentityRecorded
|
|
461
521
|
? "identity recorded, waiting for the first authenticated run"
|
|
462
522
|
: "not recorded"}`);
|
|
@@ -468,7 +528,7 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
|
|
|
468
528
|
say(` First promise: ${options.controlPlane}/promises/${repository.firstPromiseId}`);
|
|
469
529
|
}
|
|
470
530
|
if (repository.latestCandidateId !== null) {
|
|
471
|
-
say(` Newest proposal: ${options.controlPlane
|
|
531
|
+
say(` Newest proposal: ${proposalReviewLink(options.controlPlane, repository.latestCandidateId)}`);
|
|
472
532
|
}
|
|
473
533
|
}
|
|
474
534
|
return 0;
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
2
2
|
import { StoreError, dropSession, findSession, readCredentials, writeCredentials, } from "../store.js";
|
|
3
|
+
import { formatInstant } from "../local-time.js";
|
|
3
4
|
import { commandLine } from "../release.js";
|
|
4
5
|
import {} from "../wire.js";
|
|
5
6
|
function emit(options, step) {
|
|
@@ -48,7 +49,7 @@ export async function runWhoami(options) {
|
|
|
48
49
|
`Signed in as ${session.membershipDisplayName}, workspace "${session.workspaceName}" (${session.workspaceSlug}).`,
|
|
49
50
|
`Role: ${session.role}.`,
|
|
50
51
|
`This session may: ${session.scopeMeanings.join(", ")}.`,
|
|
51
|
-
`It expires at ${session.expiresAt}.`,
|
|
52
|
+
`It expires at ${formatInstant(session.expiresAt)}.`,
|
|
52
53
|
"",
|
|
53
54
|
].join("\n"));
|
|
54
55
|
}
|
package/dist/conventions.d.ts
CHANGED
|
@@ -18,8 +18,16 @@ export declare const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
|
|
|
18
18
|
/**
|
|
19
19
|
* The version of the text between the markers. It is written into the block and
|
|
20
20
|
* read back out of it, and nothing about finding the block depends on it.
|
|
21
|
+
*
|
|
22
|
+
* It moves when, and only when, `CONVENTIONS_BLOCK` changes. That block is the
|
|
23
|
+
* stable carrier: it lands in the customer's own instructions file at setup, and
|
|
24
|
+
* moving it means a pull request against every enrolled repository. Capture
|
|
25
|
+
* behavior is read every week and ships in `CAPTURE_BEHAVIOR`, which the server
|
|
26
|
+
* sends at the start of every session and which is versioned by the agent
|
|
27
|
+
* instructions marker instead. A bump here with no change to the block would
|
|
28
|
+
* rewrite twelve repositories to say exactly what they already said.
|
|
21
29
|
*/
|
|
22
|
-
export declare const CONVENTIONS_VERSION =
|
|
30
|
+
export declare const CONVENTIONS_VERSION = 14;
|
|
23
31
|
export declare function managedByLine(version: number): string;
|
|
24
32
|
/**
|
|
25
33
|
* Which version of the block a file already carries, or nothing when it carries
|
package/dist/conventions.js
CHANGED
|
@@ -22,8 +22,16 @@ export const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
|
|
|
22
22
|
/**
|
|
23
23
|
* The version of the text between the markers. It is written into the block and
|
|
24
24
|
* read back out of it, and nothing about finding the block depends on it.
|
|
25
|
+
*
|
|
26
|
+
* It moves when, and only when, `CONVENTIONS_BLOCK` changes. That block is the
|
|
27
|
+
* stable carrier: it lands in the customer's own instructions file at setup, and
|
|
28
|
+
* moving it means a pull request against every enrolled repository. Capture
|
|
29
|
+
* behavior is read every week and ships in `CAPTURE_BEHAVIOR`, which the server
|
|
30
|
+
* sends at the start of every session and which is versioned by the agent
|
|
31
|
+
* instructions marker instead. A bump here with no change to the block would
|
|
32
|
+
* rewrite twelve repositories to say exactly what they already said.
|
|
25
33
|
*/
|
|
26
|
-
export const CONVENTIONS_VERSION =
|
|
34
|
+
export const CONVENTIONS_VERSION = 14;
|
|
27
35
|
/** Both spellings: the stable marker, and the versioned one version 1 wrote. */
|
|
28
36
|
const START_MARKER = /<!-- balladeer:conventions:start(?: v(\d{1,4}))? -->/g;
|
|
29
37
|
const END_MARKER = /<!-- balladeer:conventions:end -->/g;
|