balladeer 1.0.1 → 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 +41 -33
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +13 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +39 -21
- package/dist/client.d.ts +2 -2
- package/dist/client.js +7 -4
- package/dist/commands/affected.js +6 -6
- package/dist/commands/check-seals.js +4 -4
- package/dist/commands/discover.js +5 -4
- package/dist/commands/prepare.js +2 -1
- package/dist/commands/propose.js +5 -4
- package/dist/commands/repositories.js +1 -0
- package/dist/commands/session.js +17 -4
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.js +156 -75
- package/dist/commands/status.js +41 -18
- package/dist/commands/touch-map.js +2 -2
- package/dist/conventions.d.ts +1 -1
- package/dist/conventions.js +1 -1
- package/dist/copy.d.ts +7 -7
- package/dist/copy.js +45 -30
- package/dist/store.d.ts +1 -1
- package/dist/store.js +3 -3
- package/dist/wire.d.ts +9 -4
- package/dist/wire.js +2 -1
- package/package.json +1 -1
package/dist/commands/session.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
|
|
1
|
+
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
|
|
2
2
|
import { headCommit } from "../git.js";
|
|
3
3
|
import { repositoryHint } from "../repository.js";
|
|
4
4
|
import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
|
|
5
5
|
import { StoreError, readCredentials } from "../store.js";
|
|
6
|
-
import {} from "../wire.js";
|
|
6
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
7
7
|
/**
|
|
8
8
|
* The id that joins what an agent was told to what it then wrote.
|
|
9
9
|
*
|
|
@@ -63,6 +63,18 @@ export async function runSession(options) {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
catch (error) {
|
|
66
|
+
// This catch follows successful credential selection and covers only the
|
|
67
|
+
// local marker write. It must never turn an auth or --record refusal into
|
|
68
|
+
// permission to continue, or print the freshly generated but unsaved ID.
|
|
69
|
+
const filesystemError = error instanceof StoreError && error.code === "credential_store_unwritable"
|
|
70
|
+
? error.cause
|
|
71
|
+
: error;
|
|
72
|
+
const permissionCode = filesystemError instanceof Error && "code" in filesystemError
|
|
73
|
+
? filesystemError.code
|
|
74
|
+
: undefined;
|
|
75
|
+
if (permissionCode === "EPERM" || permissionCode === "EACCES") {
|
|
76
|
+
return fail("session_store_permission_denied", `No session stamp was saved: local file permissions refused the write (${permissionCode}). Your saved authenticated connection is unchanged; this command has not checked it with the server. Continue already-authorized MCP reads, coding and explicitly requested capture without the optional session field. Do not invent an ID, add a session trailer, or run session --record for this unsaved stamp. Do not broaden filesystem access or move credentials to retry this write. Capture still requires the person's request or accepted offer, and human meaning approval is unchanged.`, 4);
|
|
77
|
+
}
|
|
66
78
|
return fail(error instanceof StoreError ? error.code : "session_store_unwritable", error instanceof StoreError ? error.message : String(error), 4);
|
|
67
79
|
}
|
|
68
80
|
const trailer = agentSessionTrailerLine(session.sessionId);
|
|
@@ -83,7 +95,7 @@ export async function runSession(options) {
|
|
|
83
95
|
say("");
|
|
84
96
|
say(` ${trailer}`);
|
|
85
97
|
say("");
|
|
86
|
-
say(`and run
|
|
98
|
+
say(`and run \`${CLI_INVOCATION} session --record\` once the commit exists, so a check that goes red on it can be read back against what you were told before you started. Balladeer is sent the session id and the commit SHA, and nothing else.`);
|
|
87
99
|
return 0;
|
|
88
100
|
}
|
|
89
101
|
async function recordCommit(options, agent, emit, say, fail) {
|
|
@@ -96,12 +108,13 @@ async function recordCommit(options, agent, emit, say, fail) {
|
|
|
96
108
|
// Said in full rather than as a code. The likeliest reader of this line is
|
|
97
109
|
// an agent that forgot the trailer, and the remedy is one line it can add
|
|
98
110
|
// to the commit it just made.
|
|
99
|
-
return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id
|
|
111
|
+
return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id ${CLI_INVOCATION} session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
|
|
100
112
|
}
|
|
101
113
|
const call = await callAgentTool(agent, "record_session_commit", {
|
|
102
114
|
session: sessionId,
|
|
103
115
|
commitSha: head.sha,
|
|
104
116
|
});
|
|
117
|
+
reportAgentEnforcementWarning(call, options);
|
|
105
118
|
if (call.kind !== "result") {
|
|
106
119
|
return fail(call.kind === "tool_refusal" ? "refused" : call.kind, call.kind === "tool_refusal"
|
|
107
120
|
? call.text
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export type SetupOptions = Readonly<{
|
|
|
14
14
|
* one checkout can honestly do from here, and the run says so per repository.
|
|
15
15
|
*/
|
|
16
16
|
repositories?: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Connect an invited teammate to a repository the workspace already holds.
|
|
19
|
+
* This is deliberately a refusal boundary: if the repository is absent, the
|
|
20
|
+
* run stops rather than turning an invitation into authority to enroll it.
|
|
21
|
+
*/
|
|
22
|
+
existingOnly?: boolean;
|
|
17
23
|
/**
|
|
18
24
|
* Repair the two files a previous setup wrote in this repository, and do
|
|
19
25
|
* nothing else. No pairing, no enrollment, no credential, no network: a
|
package/dist/commands/setup.js
CHANGED
|
@@ -2,7 +2,7 @@ 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, proposalReviewLink, request, } from "../client.js";
|
|
5
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
|
|
6
6
|
import { writeConventions } from "../conventions.js";
|
|
7
7
|
import { APPROVE_IN_THE_RIGHT_WORKSPACE, DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
|
|
8
8
|
import { ghLogin, readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
|
|
@@ -12,7 +12,6 @@ import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopSt
|
|
|
12
12
|
import { selectAgent } from "../agent.js";
|
|
13
13
|
import { findLegacyInstall, legacyMessage } from "../legacy.js";
|
|
14
14
|
import { formatInstant } from "../local-time.js";
|
|
15
|
-
import { CONVENTIONS_VERSION } from "../conventions.js";
|
|
16
15
|
import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
|
|
17
16
|
import { hostHint, repositoryHint } from "../repository.js";
|
|
18
17
|
import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
|
|
@@ -606,13 +605,26 @@ async function runRemainingSteps(options, credentials, session) {
|
|
|
606
605
|
if (view !== undefined) {
|
|
607
606
|
const step3 = await stepAgent(options, credentials, session, view, name, published);
|
|
608
607
|
view = step3 ?? view;
|
|
609
|
-
view =
|
|
608
|
+
view =
|
|
609
|
+
(options.existingOnly === true
|
|
610
|
+
? reportInviteeCi(options, view)
|
|
611
|
+
: await stepCi(options, session, view, name)) ?? view;
|
|
610
612
|
await stepPromise(options, session, view, published);
|
|
611
613
|
}
|
|
612
614
|
const finalState = await readState(options, session);
|
|
613
615
|
printReceipts(options, session, finalState ?? state, view);
|
|
614
616
|
return 0;
|
|
615
617
|
}
|
|
618
|
+
const ENFORCEMENT_UNAVAILABLE_WARNING = "This repository is not connected to Balladeer enforcement yet. You can continue onboarding and record promises, but Balladeer cannot track whether this repository's promises are enforced until its CI reports an authenticated run.";
|
|
619
|
+
const NO_REPOSITORY_WARNING = "No repository in this workspace is connected for this checkout yet. You can continue onboarding in Balladeer, but a repository must be connected before its promises can be recorded and their enforcement tracked.";
|
|
620
|
+
function warnEnforcementUnavailable(options, message = ENFORCEMENT_UNAVAILABLE_WARNING) {
|
|
621
|
+
say(options, ` WARNING ${message}`);
|
|
622
|
+
emit(options, {
|
|
623
|
+
step: "warning",
|
|
624
|
+
code: "enforcement_unavailable",
|
|
625
|
+
message,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
616
628
|
/**
|
|
617
629
|
* Hands over the GitHub login this machine is signed in as, so a run this
|
|
618
630
|
* person starts reads as their name rather than as a handle.
|
|
@@ -699,6 +711,15 @@ async function stepRepository(options, session, state, name, role = "primary") {
|
|
|
699
711
|
});
|
|
700
712
|
return { view };
|
|
701
713
|
}
|
|
714
|
+
if (options.existingOnly === true) {
|
|
715
|
+
const reason = `${name} is not already in ${session.workspaceName}, so this teammate run left repository enrollment unchanged`;
|
|
716
|
+
say(options, enrollmentHeadline(role, `${name} is not connected onboarding continues`));
|
|
717
|
+
say(options, ` ${reason}.`);
|
|
718
|
+
say(options, ` You can keep using Balladeer in the browser. A workspace administrator can connect a repository later; this run will not add one or change CI.`);
|
|
719
|
+
warnEnforcementUnavailable(options, NO_REPOSITORY_WARNING);
|
|
720
|
+
emit(options, { step: "repository", status: "not_connected", repository: name, reason });
|
|
721
|
+
return { view: undefined };
|
|
722
|
+
}
|
|
702
723
|
if (!/^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(name) || name === "unknown/unknown") {
|
|
703
724
|
return blockedRepository(options, role, name, "this directory has no GitHub origin remote I could read", "Run this command inside a repository with a GitHub origin, or name one with --repository owner/name.");
|
|
704
725
|
}
|
|
@@ -907,7 +928,69 @@ async function proveAgent(agent) {
|
|
|
907
928
|
* is not mistaken for this one's.
|
|
908
929
|
*/
|
|
909
930
|
function credentialOnThisMachine(agents, controlPlane, repositoryId) {
|
|
910
|
-
return agents.
|
|
931
|
+
return agents.find((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Reconcile every local host surface whether the bearer was issued now or was
|
|
935
|
+
* already on this machine. A credential is not a project MCP installation:
|
|
936
|
+
* another checkout, a deleted uncommitted file, or an interrupted earlier run
|
|
937
|
+
* can leave the first present and the second absent.
|
|
938
|
+
*/
|
|
939
|
+
async function configureAgentHost(options, repositoryId, name, published) {
|
|
940
|
+
const root = await repositoryRoot(options.cwd);
|
|
941
|
+
const files = [];
|
|
942
|
+
let merged;
|
|
943
|
+
let conventions;
|
|
944
|
+
if (root !== undefined) {
|
|
945
|
+
merged = mergeMcpConfig(root, stdioEntry(repositoryId, published), options.controlPlane);
|
|
946
|
+
if (merged.kind === "written") {
|
|
947
|
+
if (merged.changed)
|
|
948
|
+
files.push(MCP_CONFIG_FILE);
|
|
949
|
+
conventions = writeConventions(root);
|
|
950
|
+
if (conventions.kind === "written" && conventions.changed)
|
|
951
|
+
files.push(conventions.file);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
root,
|
|
956
|
+
files,
|
|
957
|
+
merged,
|
|
958
|
+
conventions,
|
|
959
|
+
desktop: connectClaudeDesktop(options, repositoryId, name),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function reportAgentHost(options, configured, published) {
|
|
963
|
+
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.`);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
if (configured.merged?.kind === "refused") {
|
|
968
|
+
say(options, ` ${configured.merged.reason}`);
|
|
969
|
+
say(options, " Merge this block into it yourself:");
|
|
970
|
+
for (const line of configured.merged.block.split("\n"))
|
|
971
|
+
say(options, ` ${line}`);
|
|
972
|
+
}
|
|
973
|
+
if (configured.conventions?.kind === "refused") {
|
|
974
|
+
say(options, ` ${configured.conventions.reason}`);
|
|
975
|
+
say(options, ` Repair the markers in ${configured.conventions.file}, or delete the block between them, then run this command again.`);
|
|
976
|
+
}
|
|
977
|
+
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.`);
|
|
979
|
+
if (published === null && !existsSync(checkoutEntryPath())) {
|
|
980
|
+
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
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const line of configured.desktop.lines)
|
|
985
|
+
say(options, line);
|
|
986
|
+
for (const line of hostNextSteps())
|
|
987
|
+
say(options, line);
|
|
988
|
+
say(options, published === null
|
|
989
|
+
? ` 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.`);
|
|
991
|
+
if (configured.files.length > 0) {
|
|
992
|
+
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
|
+
}
|
|
911
994
|
}
|
|
912
995
|
/**
|
|
913
996
|
* Whose credential this step is about: this machine's.
|
|
@@ -932,15 +1015,36 @@ function credentialOnThisMachine(agents, controlPlane, repositoryId) {
|
|
|
932
1015
|
*/
|
|
933
1016
|
async function stepAgent(options, credentials, session, view, name, published) {
|
|
934
1017
|
const heldHere = credentialOnThisMachine(credentials.agents, options.controlPlane, view.id);
|
|
935
|
-
if (heldHere && view.agentConfigured) {
|
|
936
|
-
|
|
1018
|
+
if (heldHere !== undefined && view.agentConfigured) {
|
|
1019
|
+
const configured = await configureAgentHost(options, view.id, name, published);
|
|
1020
|
+
const proof = await proveAgent(heldHere);
|
|
1021
|
+
const repaired = configured.files.length > 0 ? ` repaired ${configured.files.join(" and ")}` : "";
|
|
1022
|
+
say(options, proof.kind === "answered"
|
|
1023
|
+
? `Step 3 of 5 This coding agent is already connected on this machine${repaired}`
|
|
1024
|
+
: `Step 3 of 5 This machine holds an agent credential not yet proven${repaired}`);
|
|
937
1025
|
say(options, ` ${name}'s credential is in ${credentialsPath(options.environment)}, so nothing was issued and nothing was rotated. A credential belongs to the machine it was issued on: another machine runs this command there to get its own.`);
|
|
1026
|
+
if (proof.kind === "answered") {
|
|
1027
|
+
say(options, ` Balladeer answered a get_promise_setup call for ${name} over this connection just now.`);
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
say(options, ` ${proof.reason}. ${proof.nextAction}`);
|
|
1031
|
+
}
|
|
1032
|
+
reportAgentHost(options, configured, published);
|
|
938
1033
|
emit(options, {
|
|
939
1034
|
step: "agent",
|
|
940
|
-
status: "already",
|
|
1035
|
+
status: proof.kind === "answered" ? "already" : "unproven",
|
|
941
1036
|
repositoryId: view.id,
|
|
942
1037
|
storedHere: true,
|
|
943
1038
|
...(view.agentConnectionId === null ? {} : { connectionId: view.agentConnectionId }),
|
|
1039
|
+
files: configured.files,
|
|
1040
|
+
...(proof.kind === "answered"
|
|
1041
|
+
? { provedBy: "get_promise_setup" }
|
|
1042
|
+
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
1043
|
+
...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
|
|
1044
|
+
...(configured.conventions?.kind === "refused"
|
|
1045
|
+
? { conventions: configured.conventions.reason }
|
|
1046
|
+
: {}),
|
|
1047
|
+
...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
|
|
944
1048
|
});
|
|
945
1049
|
return undefined;
|
|
946
1050
|
}
|
|
@@ -950,7 +1054,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
950
1054
|
// A credential in this store, and no live connection anywhere for the
|
|
951
1055
|
// repository: the one this machine holds was revoked. Holding it is not being
|
|
952
1056
|
// connected, so this issues rather than reporting the stored entry as working.
|
|
953
|
-
const revokedHere = heldHere && !view.agentConfigured;
|
|
1057
|
+
const revokedHere = heldHere !== undefined && !view.agentConfigured;
|
|
954
1058
|
let packet;
|
|
955
1059
|
try {
|
|
956
1060
|
packet = await request(options.controlPlane, {
|
|
@@ -980,30 +1084,13 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
980
1084
|
catch (error) {
|
|
981
1085
|
return blockedAgent(options, view, error instanceof StoreError ? error.message : "the credential store could not be written");
|
|
982
1086
|
}
|
|
983
|
-
const
|
|
984
|
-
const files = [];
|
|
985
|
-
let merged;
|
|
986
|
-
let conventions;
|
|
987
|
-
if (root !== undefined) {
|
|
988
|
-
// The stdio forwarder, bound to this repository. It reads the bearer from
|
|
989
|
-
// the credential store this step just wrote, so the connection works from
|
|
990
|
-
// here with nothing further for anyone to set, and the file carries no
|
|
991
|
-
// secret.
|
|
992
|
-
merged = mergeMcpConfig(root, stdioEntry(packet.repositoryId, published), options.controlPlane);
|
|
993
|
-
if (merged.kind === "written") {
|
|
994
|
-
if (merged.changed)
|
|
995
|
-
files.push(MCP_CONFIG_FILE);
|
|
996
|
-
conventions = writeConventions(root);
|
|
997
|
-
if (conventions.kind === "written" && conventions.changed)
|
|
998
|
-
files.push(conventions.file);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1087
|
+
const configured = await configureAgentHost(options, packet.repositoryId, name, published);
|
|
1001
1088
|
// The probe runs after the files, because the files are not what it proves.
|
|
1002
1089
|
// The credential, the endpoint, and the frames this command's own forwarder
|
|
1003
1090
|
// sends are.
|
|
1004
1091
|
const proof = await proveAgent(agent);
|
|
1005
|
-
const wrote = merged?.kind === "written" && conventions?.kind === "written"
|
|
1006
|
-
? `, ${MCP_CONFIG_FILE} and ${conventions.file} updated`
|
|
1092
|
+
const wrote = configured.merged?.kind === "written" && configured.conventions?.kind === "written"
|
|
1093
|
+
? `, ${MCP_CONFIG_FILE} and ${configured.conventions.file} updated`
|
|
1007
1094
|
: "";
|
|
1008
1095
|
say(options, proof.kind === "answered"
|
|
1009
1096
|
? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
|
|
@@ -1020,45 +1107,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
1020
1107
|
say(options, ` The credential is stored, but ${proof.reason}, so this is not reported as connected.`);
|
|
1021
1108
|
say(options, ` ${proof.nextAction}`);
|
|
1022
1109
|
}
|
|
1023
|
-
|
|
1024
|
-
say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
|
|
1025
|
-
}
|
|
1026
|
-
else {
|
|
1027
|
-
if (merged?.kind === "refused") {
|
|
1028
|
-
say(options, ` ${merged.reason}`);
|
|
1029
|
-
say(options, " Merge this block into it yourself:");
|
|
1030
|
-
for (const line of merged.block.split("\n"))
|
|
1031
|
-
say(options, ` ${line}`);
|
|
1032
|
-
}
|
|
1033
|
-
if (conventions?.kind === "refused") {
|
|
1034
|
-
say(options, ` ${conventions.reason}`);
|
|
1035
|
-
say(options, ` Repair the markers in ${conventions.file}, or delete the block between them, then run this command again.`);
|
|
1036
|
-
}
|
|
1037
|
-
if (merged?.kind === "written") {
|
|
1038
|
-
// Nothing further is asked of anybody about the credential. The entry runs
|
|
1039
|
-
// this command's own forwarder, which reads the bearer out of the store
|
|
1040
|
-
// above, so there is no variable to export and no secret to carry by hand.
|
|
1041
|
-
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.`);
|
|
1042
|
-
if (published === null && !existsSync(checkoutEntryPath())) {
|
|
1043
|
-
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.`);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
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);
|
|
1054
|
-
// Said on every ending of this step, including the ones where no file was
|
|
1055
|
-
// written: whoever is reading has to configure that host by hand, and they
|
|
1056
|
-
// need the same two facts about when a host picks an entry up.
|
|
1057
|
-
for (const line of hostNextSteps())
|
|
1058
|
-
say(options, line);
|
|
1059
|
-
if (files.length > 0) {
|
|
1060
|
-
say(options, ` ${files.join(" and ")} ${files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
|
|
1061
|
-
}
|
|
1110
|
+
reportAgentHost(options, configured, published);
|
|
1062
1111
|
emit(options, {
|
|
1063
1112
|
step: "agent",
|
|
1064
1113
|
status: proof.kind === "answered" ? "connected" : "unproven",
|
|
@@ -1066,13 +1115,15 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
1066
1115
|
connectionId: packet.connectionId,
|
|
1067
1116
|
storedHere: true,
|
|
1068
1117
|
...(connectedElsewhere ? { alreadyConnectedElsewhere: true } : {}),
|
|
1069
|
-
files,
|
|
1118
|
+
files: configured.files,
|
|
1070
1119
|
...(proof.kind === "answered"
|
|
1071
1120
|
? { provedBy: "get_promise_setup" }
|
|
1072
1121
|
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
1073
|
-
...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
|
|
1074
|
-
...(conventions?.kind === "refused"
|
|
1075
|
-
|
|
1122
|
+
...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
|
|
1123
|
+
...(configured.conventions?.kind === "refused"
|
|
1124
|
+
? { conventions: configured.conventions.reason }
|
|
1125
|
+
: {}),
|
|
1126
|
+
...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
|
|
1076
1127
|
});
|
|
1077
1128
|
return undefined;
|
|
1078
1129
|
}
|
|
@@ -1084,6 +1135,34 @@ function blockedAgent(options, view, reason) {
|
|
|
1084
1135
|
return undefined;
|
|
1085
1136
|
}
|
|
1086
1137
|
/* ----------------------------- Step 4 ------------------------------------ */
|
|
1138
|
+
/**
|
|
1139
|
+
* A teammate run reports CI truth and never becomes repository maintenance.
|
|
1140
|
+
* Adding or updating a workflow, setting repository variables, and opening a
|
|
1141
|
+
* pull request all belong to the administrator/maintainer path.
|
|
1142
|
+
*/
|
|
1143
|
+
function reportInviteeCi(options, view) {
|
|
1144
|
+
if (view.ciConfigured) {
|
|
1145
|
+
say(options, `Step 4 of 5 CI already connected ${view.ciValidatedRunCount} authenticated run${view.ciValidatedRunCount === 1 ? "" : "s"} observed`);
|
|
1146
|
+
emit(options, {
|
|
1147
|
+
step: "ci",
|
|
1148
|
+
status: "connected",
|
|
1149
|
+
repositoryId: view.id,
|
|
1150
|
+
validatedRunCount: view.ciValidatedRunCount,
|
|
1151
|
+
...(view.ciFirstValidatedAt === null ? {} : { firstValidatedAt: view.ciFirstValidatedAt }),
|
|
1152
|
+
});
|
|
1153
|
+
return undefined;
|
|
1154
|
+
}
|
|
1155
|
+
say(options, "Step 4 of 5 CI is not connected teammate onboarding continues");
|
|
1156
|
+
say(options, " This run did not write a workflow, set GitHub variables, push a branch, or open a pull request.");
|
|
1157
|
+
warnEnforcementUnavailable(options);
|
|
1158
|
+
emit(options, {
|
|
1159
|
+
step: "ci",
|
|
1160
|
+
status: "not_changed",
|
|
1161
|
+
repositoryId: view.id,
|
|
1162
|
+
reason: "Teammate setup leaves repository CI unchanged.",
|
|
1163
|
+
});
|
|
1164
|
+
return undefined;
|
|
1165
|
+
}
|
|
1087
1166
|
async function stepCi(options, session, view, name) {
|
|
1088
1167
|
// Checked on every run, and BEFORE the connected branch below, because an
|
|
1089
1168
|
// already-connected repository is exactly the one this happens to. Balladeer
|
|
@@ -1342,7 +1421,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1342
1421
|
const state = await readState(options, session);
|
|
1343
1422
|
const current = state?.repositories.find((repository) => repository.id === view.id) ?? view;
|
|
1344
1423
|
if (current.firstPromiseId !== null) {
|
|
1345
|
-
const promiseUrl =
|
|
1424
|
+
const promiseUrl = reviewLink(options.controlPlane, `promises/${encodeURIComponent(current.firstPromiseId)}`, session.workspaceId);
|
|
1346
1425
|
say(options, "Step 5 of 5 A named person agreed to the first promise");
|
|
1347
1426
|
say(options, ` ${promiseUrl}`);
|
|
1348
1427
|
say(options, " It stands agreed and is not yet protected. Protection starts on its own the moment a verifier qualifies, once the verifier pull request has merged. Nobody is asked to turn it on.");
|
|
@@ -1355,7 +1434,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1355
1434
|
return;
|
|
1356
1435
|
}
|
|
1357
1436
|
if (current.latestCandidateId !== null) {
|
|
1358
|
-
const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId);
|
|
1437
|
+
const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId, session.workspaceId);
|
|
1359
1438
|
say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
|
|
1360
1439
|
say(options, ` Review it: ${reviewUrl}`);
|
|
1361
1440
|
say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
|
|
@@ -1415,10 +1494,12 @@ function printReceipts(options, session, state, view) {
|
|
|
1415
1494
|
// promise the receipt below it said already stood agreed.
|
|
1416
1495
|
const promiseId = current?.firstPromiseId ?? null;
|
|
1417
1496
|
const candidateId = current?.latestCandidateId ?? null;
|
|
1418
|
-
const promiseUrl = promiseId === null
|
|
1497
|
+
const promiseUrl = promiseId === null
|
|
1498
|
+
? undefined
|
|
1499
|
+
: reviewLink(options.controlPlane, `promises/${encodeURIComponent(promiseId)}`, state.workspace.id);
|
|
1419
1500
|
const reviewUrl = promiseId !== null || candidateId === null
|
|
1420
1501
|
? undefined
|
|
1421
|
-
: proposalReviewLink(options.controlPlane, candidateId);
|
|
1502
|
+
: proposalReviewLink(options.controlPlane, candidateId, state.workspace.id);
|
|
1422
1503
|
const promiseLink = promiseUrl ?? reviewUrl;
|
|
1423
1504
|
const promiseSentence = promiseId !== null
|
|
1424
1505
|
? "Agreed. A named person agreed to this repository's first promise."
|
package/dist/commands/status.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
|
|
2
|
-
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, request, } from "../client.js";
|
|
1
|
+
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
|
|
2
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
|
|
3
3
|
import { repositoryRoot } from "../git.js";
|
|
4
4
|
import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
|
|
5
5
|
import { formatInstant } from "../local-time.js";
|
|
@@ -7,7 +7,7 @@ import { commandLine } from "../release.js";
|
|
|
7
7
|
import { repositoryHint } from "../repository.js";
|
|
8
8
|
import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
|
|
9
9
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
10
|
-
import {} from "../wire.js";
|
|
10
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
11
11
|
/**
|
|
12
12
|
* The bounded code for "this machine holds no credential for that repository".
|
|
13
13
|
* It is its own code rather than the session's: an agent branching on
|
|
@@ -104,7 +104,23 @@ async function reportBalladeer(options) {
|
|
|
104
104
|
}
|
|
105
105
|
const here = options.repo ?? repositoryHint(options.cwd);
|
|
106
106
|
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
|
|
107
|
-
const
|
|
107
|
+
const storedSession = findSession(credentials, options.controlPlane);
|
|
108
|
+
const repositoryScoped = options.repo !== undefined ||
|
|
109
|
+
options.repository !== undefined ||
|
|
110
|
+
here !== "unknown/unknown" ||
|
|
111
|
+
(await repositoryRoot(options.cwd)) !== undefined;
|
|
112
|
+
const hasBoundSession = typeof storedSession?.workspaceId === "string" && storedSession.workspaceId.trim().length > 0;
|
|
113
|
+
// A setup session belongs to one workspace, not to every repository whose
|
|
114
|
+
// agent happens to be stored on this machine. Establish that relationship
|
|
115
|
+
// before sending its bearer or fetching its workspace-wide context.
|
|
116
|
+
const session = hasBoundSession &&
|
|
117
|
+
(!repositoryScoped ||
|
|
118
|
+
(selection.kind === "agent" && selection.agent.workspaceId === storedSession.workspaceId))
|
|
119
|
+
? storedSession
|
|
120
|
+
: undefined;
|
|
121
|
+
const missingWorkspace = () => sayWorkspaceUnavailable(emit, say, storedSession === undefined ? "no_stored_setup_session" : "setup_session_scope_unverified", storedSession === undefined
|
|
122
|
+
? noSessionSentence(options.controlPlane)
|
|
123
|
+
: "An optional workspace view was skipped because the saved setup session could not be tied to this request. No workspace read was sent.");
|
|
108
124
|
// The credential this machine holds is what this repository is read over, so
|
|
109
125
|
// its absence is the first thing said, and it is said in its own words. What
|
|
110
126
|
// the founder got instead was the workspace half's refusal, "Balladeer refused
|
|
@@ -120,21 +136,13 @@ async function reportBalladeer(options) {
|
|
|
120
136
|
else {
|
|
121
137
|
options.write(`${message}\n`);
|
|
122
138
|
}
|
|
123
|
-
|
|
124
|
-
sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
// A different question, over a different credential, so it is still
|
|
128
|
-
// answered underneath. Its outcome never becomes this run's exit code:
|
|
129
|
-
// this repository went unreported whatever the workspace read did.
|
|
130
|
-
await reportWorkspace(options, session, here, emit, say, true);
|
|
131
|
-
}
|
|
139
|
+
missingWorkspace();
|
|
132
140
|
return 4;
|
|
133
141
|
}
|
|
134
142
|
if (selection.kind === "refused" && session === undefined) {
|
|
135
143
|
// Nothing stored can answer anything, so this names the form that always
|
|
136
144
|
// works: the one this copy was run as.
|
|
137
|
-
return fail("nothing_stored", `${selection.reason} No Balladeer setup session is stored for ${options.controlPlane} either. Run: ${commandLine(null, "setup")}`, 4);
|
|
145
|
+
return fail("nothing_stored", `${selection.reason} ${storedSession === undefined ? `No Balladeer setup session is stored for ${options.controlPlane} either.` : "The stored setup session was not read because its workspace could not be established for this request."} Run: ${commandLine(null, "setup")}`, 4);
|
|
138
146
|
}
|
|
139
147
|
// One promise, by id: a different question, answered over the same connection
|
|
140
148
|
// and on its own. Somebody who pasted the repair line wants what the failing
|
|
@@ -150,9 +158,13 @@ async function reportBalladeer(options) {
|
|
|
150
158
|
let reported = false;
|
|
151
159
|
if (selection.kind === "agent") {
|
|
152
160
|
reported = await reportThisRepository(options, selection.agent, emit, say);
|
|
161
|
+
// A revoked or mismatched repository connection must not become a
|
|
162
|
+
// successful repository report through a different credential.
|
|
163
|
+
if (!reported && repositoryScoped)
|
|
164
|
+
return 4;
|
|
153
165
|
}
|
|
154
166
|
if (session === undefined) {
|
|
155
|
-
|
|
167
|
+
missingWorkspace();
|
|
156
168
|
return reported ? 0 : 4;
|
|
157
169
|
}
|
|
158
170
|
return reportWorkspace(options, session, here, emit, say, reported);
|
|
@@ -167,6 +179,7 @@ async function reportBalladeer(options) {
|
|
|
167
179
|
*/
|
|
168
180
|
async function reportThisRepository(options, agent, emit, say) {
|
|
169
181
|
const call = await callAgentTool(agent, "get_promise_setup", {});
|
|
182
|
+
reportAgentEnforcementWarning(call, options);
|
|
170
183
|
if (call.kind !== "result") {
|
|
171
184
|
say(`This repository could not be read over its Balladeer agent connection: ${reason(call)}`);
|
|
172
185
|
return false;
|
|
@@ -277,6 +290,7 @@ async function reportStaleMarkers(options, agent, emit, say) {
|
|
|
277
290
|
*/
|
|
278
291
|
async function reportOnePromise(options, promiseId, agent, emit, say) {
|
|
279
292
|
const call = await callAgentTool(agent, "get_promise", { promiseId });
|
|
293
|
+
reportAgentEnforcementWarning(call, options);
|
|
280
294
|
if (call.kind !== "result") {
|
|
281
295
|
const message = `${promiseId} could not be read over this repository's Balladeer agent connection: ${reason(call)}`;
|
|
282
296
|
emit({ step: "promise", status: "unreadable", promiseId, message });
|
|
@@ -409,7 +423,7 @@ async function reportSealedPaths(options, emit, say) {
|
|
|
409
423
|
say(` ${promise.sealedPath}${promise.title === undefined ? "" : ` ${promise.title}`}`);
|
|
410
424
|
if (sealed.length > shown.length)
|
|
411
425
|
say(` and ${sealed.length - shown.length} more, not listed here.`);
|
|
412
|
-
say(
|
|
426
|
+
say(` Run \`${CLI_INVOCATION} check-seals\` before you push to find out whether your change broke one.`);
|
|
413
427
|
}
|
|
414
428
|
function reason(call) {
|
|
415
429
|
switch (call.kind) {
|
|
@@ -476,6 +490,15 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
|
|
|
476
490
|
}
|
|
477
491
|
return failOutside(options, emit, code, "Balladeer could not report this workspace.", 5);
|
|
478
492
|
}
|
|
493
|
+
if (state?.workspace?.id !== session.workspaceId) {
|
|
494
|
+
sayWorkspaceUnavailable(emit, say, "setup_workspace_mismatch", "The setup session answered for a different workspace, so none of its workspace context was reported.");
|
|
495
|
+
return alreadyReported ? 0 : 4;
|
|
496
|
+
}
|
|
497
|
+
const scopedReviewLink = (url) => {
|
|
498
|
+
const scoped = new URL(url);
|
|
499
|
+
scoped.searchParams.set("workspaceId", state.workspace.id);
|
|
500
|
+
return scoped.toString();
|
|
501
|
+
};
|
|
479
502
|
const active = state.repositories.filter((repository) => repository.status === "active");
|
|
480
503
|
emit({
|
|
481
504
|
step: "receipt",
|
|
@@ -525,10 +548,10 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
|
|
|
525
548
|
// awaiting them, and a rejected proposal is listed nowhere at all.
|
|
526
549
|
say(` Promises: ${repository.promiseCount} agreed, ${repository.candidateCount} proposed and awaiting a person.`);
|
|
527
550
|
if (repository.firstPromiseId !== null) {
|
|
528
|
-
say(` First promise: ${options.controlPlane
|
|
551
|
+
say(` First promise: ${scopedReviewLink(reviewLink(options.controlPlane, `promises/${encodeURIComponent(repository.firstPromiseId)}`))}`);
|
|
529
552
|
}
|
|
530
553
|
if (repository.latestCandidateId !== null) {
|
|
531
|
-
say(` Newest proposal: ${proposalReviewLink(options.controlPlane, repository.latestCandidateId)}`);
|
|
554
|
+
say(` Newest proposal: ${scopedReviewLink(proposalReviewLink(options.controlPlane, repository.latestCandidateId))}`);
|
|
532
555
|
}
|
|
533
556
|
}
|
|
534
557
|
return 0;
|
|
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { runCommand } from "../gh.js";
|
|
5
5
|
import { repositoryRoot } from "../git.js";
|
|
6
6
|
import { CONTINUITY_DIRECTORY, TOUCH_MAP_FILE, buildTouchMap, executedPaths, mappablePath, measurableWithNode, readCoverageDirectory, readLocalPackages, repositoryPath, serializeTouchMap, touchMapBody, touchMapEnvironment, verifierCwd, verifierDigest, } from "../touch-map.js";
|
|
7
|
-
import {} from "../wire.js";
|
|
7
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
/** A verifier gets this long before the measurement gives up on it. */
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
10
|
/** However long a package asks for, no verifier holds this command longer. */
|
|
@@ -214,7 +214,7 @@ export async function runTouchMap(options) {
|
|
|
214
214
|
say(` and ${map.unmapped.length - NAMED_LIMIT} more, not listed here.`);
|
|
215
215
|
}
|
|
216
216
|
say("");
|
|
217
|
-
say(
|
|
217
|
+
say(`Ask it which promises a change touches with: ${CLI_INVOCATION} affected <paths...>`);
|
|
218
218
|
return 0;
|
|
219
219
|
}
|
|
220
220
|
/**
|
package/dist/conventions.d.ts
CHANGED
|
@@ -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 =
|
|
30
|
+
export declare const CONVENTIONS_VERSION = 16;
|
|
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
|
package/dist/conventions.js
CHANGED
|
@@ -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 =
|
|
34
|
+
export const CONVENTIONS_VERSION = 16;
|
|
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;
|