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.
@@ -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 \`balladeer 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.`);
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 balladeer session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
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
@@ -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
@@ -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 = (await stepCi(options, session, view, name)) ?? 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.some((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
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
- say(options, "Step 3 of 5 This coding agent is already connected on this machine");
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 root = await repositoryRoot(options.cwd);
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
- if (root === undefined) {
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" ? { conventions: conventions.reason } : {}),
1075
- ...(desktop.json === undefined ? {} : { claudeDesktop: desktop.json }),
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 = `${options.controlPlane}/promises/${current.firstPromiseId}`;
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 ? undefined : `${options.controlPlane}/promises/${promiseId}`;
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."
@@ -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 session = findSession(credentials, options.controlPlane);
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
- if (session === undefined) {
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
- sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
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(" Run `balladeer check-seals` before you push to find out whether your change broke one.");
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}/promises/${repository.firstPromiseId}`);
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("Ask it which promises a change touches with: balladeer affected <paths...>");
217
+ say(`Ask it which promises a change touches with: ${CLI_INVOCATION} affected <paths...>`);
218
218
  return 0;
219
219
  }
220
220
  /**
@@ -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 = 14;
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
@@ -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 = 14;
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;