balladeer 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +53 -32
  2. package/dist/agent.d.ts +5 -0
  3. package/dist/agent.js +13 -1
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +181 -24
  6. package/dist/client.d.ts +24 -2
  7. package/dist/client.js +34 -3
  8. package/dist/commands/affected.js +8 -7
  9. package/dist/commands/check-seals.js +4 -4
  10. package/dist/commands/discover.js +16 -7
  11. package/dist/commands/explain.d.ts +1 -1
  12. package/dist/commands/explain.js +1 -1
  13. package/dist/commands/invite.js +2 -1
  14. package/dist/commands/prepare.d.ts +74 -0
  15. package/dist/commands/prepare.js +218 -0
  16. package/dist/commands/propose.d.ts +10 -0
  17. package/dist/commands/propose.js +29 -6
  18. package/dist/commands/repositories.js +1 -0
  19. package/dist/commands/session.d.ts +35 -0
  20. package/dist/commands/session.js +131 -0
  21. package/dist/commands/setup.d.ts +29 -0
  22. package/dist/commands/setup.js +302 -92
  23. package/dist/commands/status.d.ts +16 -0
  24. package/dist/commands/status.js +106 -23
  25. package/dist/commands/touch-map.js +2 -2
  26. package/dist/commands/whoami.js +2 -1
  27. package/dist/conventions.d.ts +9 -1
  28. package/dist/conventions.js +9 -1
  29. package/dist/copy.d.ts +83 -7
  30. package/dist/copy.js +226 -29
  31. package/dist/desktop-config.d.ts +85 -0
  32. package/dist/desktop-config.js +217 -0
  33. package/dist/git.d.ts +15 -0
  34. package/dist/git.js +23 -0
  35. package/dist/legacy.d.ts +41 -0
  36. package/dist/legacy.js +143 -0
  37. package/dist/local-time.d.ts +66 -0
  38. package/dist/local-time.js +84 -0
  39. package/dist/mcp-config.d.ts +10 -0
  40. package/dist/mcp-config.js +8 -4
  41. package/dist/session.d.ts +84 -0
  42. package/dist/session.js +135 -0
  43. package/dist/store.d.ts +11 -1
  44. package/dist/store.js +18 -6
  45. package/dist/wire.d.ts +95 -4
  46. package/dist/wire.js +2 -1
  47. package/package.json +1 -1
@@ -2,14 +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, reviewLink, 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";
12
- import { CONVENTIONS_VERSION } from "../conventions.js";
13
+ import { findLegacyInstall, legacyMessage } from "../legacy.js";
14
+ import { formatInstant } from "../local-time.js";
13
15
  import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
14
16
  import { hostHint, repositoryHint } from "../repository.js";
15
17
  import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
@@ -126,7 +128,7 @@ function pairedLines(session) {
126
128
  ` This session may: ${session.scopeMeanings.join(", ")}.`,
127
129
  ...narrowed,
128
130
  ` ${REFUSAL_SENTENCE}`,
129
- ` It expires at ${session.expiresAt}. Revoke it any time under Connected sessions in Balladeer.`,
131
+ ` It expires at ${formatInstant(session.expiresAt)}. Revoke it any time under Connected sessions in Balladeer.`,
130
132
  ];
131
133
  }
132
134
  /**
@@ -147,8 +149,14 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
147
149
  ...opening,
148
150
  ` Open: ${approvalUri}`,
149
151
  ` The page will show this code: ${pending.userCode}`,
152
+ // A person who belongs to two workspaces approves into whichever one their
153
+ // browser is signed into, and twice that was not the one they meant: the
154
+ // repository was enrolled in the wrong workspace and an agent was connected
155
+ // there. The page now names the workspace and offers the switch, and this
156
+ // line is what makes somebody look at it.
157
+ ` ${APPROVE_IN_THE_RIGHT_WORKSPACE}`,
150
158
  ` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
151
- ` This pairing expires at ${pending.expiresAt}.`,
159
+ ` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
152
160
  " Approve in the browser, then run this command again to finish.",
153
161
  "",
154
162
  // Printed on the pairing step and nowhere else, because this is the one
@@ -244,6 +252,16 @@ function namedRepositories(options) {
244
252
  * a command that blocks for minutes and the pairing would be lost with it.
245
253
  */
246
254
  export async function runSetup(options) {
255
+ // First, before the banner and before either path writes a byte: a machine
256
+ // that still carries the July client gets one instruction and this run stops.
257
+ // Refresh is inside the check rather than outside it, because a repair writes
258
+ // the same two files a first setup writes.
259
+ const legacy = options.force === true
260
+ ? undefined
261
+ : findLegacyInstall(options.environment, options.controlPlane);
262
+ if (legacy !== undefined) {
263
+ return fail(options, "legacy_balladeer_present", legacyMessage(legacy), 6);
264
+ }
247
265
  if (options.refresh === true)
248
266
  return runRefresh(options);
249
267
  const { controlPlane } = options;
@@ -365,7 +383,7 @@ export async function runSetup(options) {
365
383
  return reportStartFailure(options, error);
366
384
  }
367
385
  if (lapsed !== undefined) {
368
- say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${lapsed.expiresAt}; here is a new one.`);
386
+ say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${formatInstant(lapsed.expiresAt)}; here is a new one.`);
369
387
  emit(options, {
370
388
  step: "pair",
371
389
  status: "expired",
@@ -488,7 +506,7 @@ function terminalRefusal(pending, code) {
488
506
  const again = "Run this command again to start a new one.";
489
507
  if (code === "pairing_expired") {
490
508
  return {
491
- message: `Pairing ${pending.userCode} expired at ${pending.expiresAt}. ${again}`,
509
+ message: `Pairing ${pending.userCode} expired at ${formatInstant(pending.expiresAt)}. ${again}`,
492
510
  status: "expired",
493
511
  };
494
512
  }
@@ -519,7 +537,7 @@ async function waitForApproval(options, credentials, pending, pollIntervalSecond
519
537
  const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
520
538
  const now = options.now ?? (() => new Date());
521
539
  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}.`);
540
+ 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
541
  let interval = pollIntervalSeconds;
524
542
  while (now().getTime() < deadline) {
525
543
  await sleep(Math.max(interval, 2) * 1000);
@@ -566,6 +584,7 @@ async function runRemainingSteps(options, credentials, session) {
566
584
  return transportFailure(options, error.message);
567
585
  return fail(options, "setup_state_unreadable", `Balladeer could not report this workspace's setup: ${refusalSentence(error)}. Nothing was changed.`, 5);
568
586
  }
587
+ await claimGithubLogin(options, session);
569
588
  const chosen = chooseRepositories(namedRepositories(options), options.cwd);
570
589
  if (chosen.kind === "mismatch") {
571
590
  return fail(options, "repository_mismatch", chosen.message, 4);
@@ -586,13 +605,64 @@ async function runRemainingSteps(options, credentials, session) {
586
605
  if (view !== undefined) {
587
606
  const step3 = await stepAgent(options, credentials, session, view, name, published);
588
607
  view = step3 ?? view;
589
- 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;
590
612
  await stepPromise(options, session, view, published);
591
613
  }
592
614
  const finalState = await readState(options, session);
593
615
  printReceipts(options, session, finalState ?? state, view);
594
616
  return 0;
595
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
+ }
628
+ /**
629
+ * Hands over the GitHub login this machine is signed in as, so a run this
630
+ * person starts reads as their name rather than as a handle.
631
+ *
632
+ * It sends a login and never a token. `gh` is the person's own tool, already
633
+ * signed in on their own machine, and the one thing read out of it here is the
634
+ * name of the account. Balladeer stores that as a claim: nothing checks it,
635
+ * nothing is granted by it, and the settings page lets the person take it back.
636
+ *
637
+ * Every failure is silent to the run and visible in the receipt. No `gh`, no
638
+ * login, an older control plane that has never heard of this call: setup goes
639
+ * on. A person cannot be stopped from enrolling a repository because a display
640
+ * detail could not be recorded.
641
+ */
642
+ async function claimGithubLogin(options, session) {
643
+ const login = await ghLogin();
644
+ if (login === undefined) {
645
+ emit(options, { step: "github_login", status: "unavailable" });
646
+ return;
647
+ }
648
+ try {
649
+ const answer = await request(options.controlPlane, {
650
+ method: "POST",
651
+ path: "/api/setup/v1/github-login",
652
+ bearer: session.token,
653
+ body: { login },
654
+ });
655
+ if (answer.claimedLogin === null) {
656
+ emit(options, { step: "github_login", status: "refused" });
657
+ return;
658
+ }
659
+ 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`);
660
+ emit(options, { step: "github_login", status: "claimed", login: answer.claimedLogin });
661
+ }
662
+ catch {
663
+ emit(options, { step: "github_login", status: "refused" });
664
+ }
665
+ }
596
666
  async function readState(options, session) {
597
667
  try {
598
668
  return await request(options.controlPlane, {
@@ -641,6 +711,15 @@ async function stepRepository(options, session, state, name, role = "primary") {
641
711
  });
642
712
  return { view };
643
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
+ }
644
723
  if (!/^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(name) || name === "unknown/unknown") {
645
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.");
646
725
  }
@@ -758,19 +837,67 @@ function blockedRepository(options, role, name, reason, next, publicFacts) {
758
837
  emit(options, { step: "repository", status: "blocked", repository: name, reason });
759
838
  return { view: undefined };
760
839
  }
761
- /**
762
- * What the person or the agent does next, for their own host.
763
- *
764
- * Two things go wrong here and look identical from the terminal: an agent host
765
- * that is already running does not pick up a project MCP entry written under it,
766
- * and a project-scoped server needs the person's approval before its tools
767
- * appear at all. Neither is a failure, and neither was mentioned anywhere, so a
768
- * truthful report of a proved connection still ended in a session with no
769
- * Balladeer tools in it and nobody knowing why.
770
- *
771
- * A restart is not part of the happy path. Setup finishes here either way, and
772
- * the next session loads the entry on its own.
773
- */
840
+ const NO_DESKTOP = { lines: [], json: undefined };
841
+ function connectClaudeDesktop(options, repositoryId, repositoryName) {
842
+ if (options.claudeDesktop === false)
843
+ return NO_DESKTOP;
844
+ // Asked for by name, or taken by default. The difference is only whether a
845
+ // machine with no Claude desktop on it hears about it: a person who typed the
846
+ // flag is owed the answer, and a person who typed nothing is not owed a line
847
+ // about an application they never mentioned.
848
+ const asked = options.claudeDesktop === true;
849
+ const location = desktopConfigLocation(options.environment);
850
+ if (location.kind === "unsupported") {
851
+ return asked
852
+ ? {
853
+ lines: [` ${location.reason}`],
854
+ json: { status: "unsupported", reason: location.reason },
855
+ }
856
+ : NO_DESKTOP;
857
+ }
858
+ const key = desktopServerKey(repositoryName, repositoryId);
859
+ const entry = desktopStdioEntry(repositoryId, options.environment, options.controlPlane);
860
+ const merged = mergeDesktopConfig(location, key, entry, repositoryId, options.controlPlane);
861
+ if (merged.kind === "absent") {
862
+ return asked
863
+ ? { lines: [` ${merged.reason}`], json: { status: "absent", reason: merged.reason } }
864
+ : NO_DESKTOP;
865
+ }
866
+ if (merged.kind === "refused") {
867
+ return {
868
+ lines: [
869
+ ` ${merged.reason}`,
870
+ " Merge this block into it yourself:",
871
+ ...merged.block.split("\n").map((line) => ` ${line}`),
872
+ ],
873
+ json: { status: "refused", reason: merged.reason },
874
+ };
875
+ }
876
+ // `unknown/unknown` is what the origin parse answers when it read nothing, and
877
+ // reading it back at somebody as though it were a repository name is worse
878
+ // than not naming the repository at all.
879
+ const named = repositoryName === "unknown/unknown" || repositoryName.length === 0
880
+ ? "this repository"
881
+ : repositoryName;
882
+ return {
883
+ lines: merged.changed
884
+ ? [
885
+ ` 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.`,
886
+ // Said only where something changed. Telling somebody to restart an
887
+ // application over a file nothing wrote to is noise, and noise is how
888
+ // the line that does matter stops being read.
889
+ ` 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.`,
890
+ ]
891
+ : [
892
+ ` Claude desktop chat already runs the current command for ${named} as ${merged.key}; I left ${merged.path} alone.`,
893
+ ],
894
+ json: {
895
+ status: merged.changed ? "connected" : "current",
896
+ key: merged.key,
897
+ path: merged.path,
898
+ },
899
+ };
900
+ }
774
901
  function hostNextSteps() {
775
902
  return [
776
903
  ` Your agent host reads ${MCP_CONFIG_FILE} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
@@ -801,7 +928,69 @@ async function proveAgent(agent) {
801
928
  * is not mistaken for this one's.
802
929
  */
803
930
  function credentialOnThisMachine(agents, controlPlane, repositoryId) {
804
- 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
+ }
805
994
  }
806
995
  /**
807
996
  * Whose credential this step is about: this machine's.
@@ -826,15 +1015,36 @@ function credentialOnThisMachine(agents, controlPlane, repositoryId) {
826
1015
  */
827
1016
  async function stepAgent(options, credentials, session, view, name, published) {
828
1017
  const heldHere = credentialOnThisMachine(credentials.agents, options.controlPlane, view.id);
829
- if (heldHere && view.agentConfigured) {
830
- 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}`);
831
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);
832
1033
  emit(options, {
833
1034
  step: "agent",
834
- status: "already",
1035
+ status: proof.kind === "answered" ? "already" : "unproven",
835
1036
  repositoryId: view.id,
836
1037
  storedHere: true,
837
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 }),
838
1048
  });
839
1049
  return undefined;
840
1050
  }
@@ -844,7 +1054,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
844
1054
  // A credential in this store, and no live connection anywhere for the
845
1055
  // repository: the one this machine holds was revoked. Holding it is not being
846
1056
  // connected, so this issues rather than reporting the stored entry as working.
847
- const revokedHere = heldHere && !view.agentConfigured;
1057
+ const revokedHere = heldHere !== undefined && !view.agentConfigured;
848
1058
  let packet;
849
1059
  try {
850
1060
  packet = await request(options.controlPlane, {
@@ -874,30 +1084,13 @@ async function stepAgent(options, credentials, session, view, name, published) {
874
1084
  catch (error) {
875
1085
  return blockedAgent(options, view, error instanceof StoreError ? error.message : "the credential store could not be written");
876
1086
  }
877
- const root = await repositoryRoot(options.cwd);
878
- const files = [];
879
- let merged;
880
- let conventions;
881
- if (root !== undefined) {
882
- // The stdio forwarder, bound to this repository. It reads the bearer from
883
- // the credential store this step just wrote, so the connection works from
884
- // here with nothing further for anyone to set, and the file carries no
885
- // secret.
886
- merged = mergeMcpConfig(root, stdioEntry(packet.repositoryId, published), options.controlPlane);
887
- if (merged.kind === "written") {
888
- if (merged.changed)
889
- files.push(MCP_CONFIG_FILE);
890
- conventions = writeConventions(root);
891
- if (conventions.kind === "written" && conventions.changed)
892
- files.push(conventions.file);
893
- }
894
- }
1087
+ const configured = await configureAgentHost(options, packet.repositoryId, name, published);
895
1088
  // The probe runs after the files, because the files are not what it proves.
896
1089
  // The credential, the endpoint, and the frames this command's own forwarder
897
1090
  // sends are.
898
1091
  const proof = await proveAgent(agent);
899
- const wrote = merged?.kind === "written" && conventions?.kind === "written"
900
- ? `, ${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`
901
1094
  : "";
902
1095
  say(options, proof.kind === "answered"
903
1096
  ? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
@@ -914,38 +1107,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
914
1107
  say(options, ` The credential is stored, but ${proof.reason}, so this is not reported as connected.`);
915
1108
  say(options, ` ${proof.nextAction}`);
916
1109
  }
917
- if (root === undefined) {
918
- say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
919
- }
920
- else {
921
- if (merged?.kind === "refused") {
922
- say(options, ` ${merged.reason}`);
923
- say(options, " Merge this block into it yourself:");
924
- for (const line of merged.block.split("\n"))
925
- say(options, ` ${line}`);
926
- }
927
- if (conventions?.kind === "refused") {
928
- say(options, ` ${conventions.reason}`);
929
- say(options, ` Repair the markers in ${conventions.file}, or delete the block between them, then run this command again.`);
930
- }
931
- if (merged?.kind === "written") {
932
- // Nothing further is asked of anybody about the credential. The entry runs
933
- // this command's own forwarder, which reads the bearer out of the store
934
- // above, so there is no variable to export and no secret to carry by hand.
935
- 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.`);
936
- if (published === null && !existsSync(checkoutEntryPath())) {
937
- 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.`);
938
- }
939
- }
940
- }
941
- // Said on every ending of this step, including the ones where no file was
942
- // written: whoever is reading has to configure that host by hand, and they
943
- // need the same two facts about when a host picks an entry up.
944
- for (const line of hostNextSteps())
945
- say(options, line);
946
- if (files.length > 0) {
947
- 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.`);
948
- }
1110
+ reportAgentHost(options, configured, published);
949
1111
  emit(options, {
950
1112
  step: "agent",
951
1113
  status: proof.kind === "answered" ? "connected" : "unproven",
@@ -953,12 +1115,15 @@ async function stepAgent(options, credentials, session, view, name, published) {
953
1115
  connectionId: packet.connectionId,
954
1116
  storedHere: true,
955
1117
  ...(connectedElsewhere ? { alreadyConnectedElsewhere: true } : {}),
956
- files,
1118
+ files: configured.files,
957
1119
  ...(proof.kind === "answered"
958
1120
  ? { provedBy: "get_promise_setup" }
959
1121
  : { reason: proof.reason, nextAction: proof.nextAction }),
960
- ...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
961
- ...(conventions?.kind === "refused" ? { conventions: conventions.reason } : {}),
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 }),
962
1127
  });
963
1128
  return undefined;
964
1129
  }
@@ -970,6 +1135,34 @@ function blockedAgent(options, view, reason) {
970
1135
  return undefined;
971
1136
  }
972
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
+ }
973
1166
  async function stepCi(options, session, view, name) {
974
1167
  // Checked on every run, and BEFORE the connected branch below, because an
975
1168
  // already-connected repository is exactly the one this happens to. Balladeer
@@ -986,7 +1179,7 @@ async function stepCi(options, session, view, name) {
986
1179
  if (!upgrading && view.ciConfigured) {
987
1180
  // "Connected" appears on this branch and nowhere else in the command: it is
988
1181
  // the one state Balladeer observed rather than recorded.
989
- say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt ?? "an earlier run"}`);
1182
+ say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt === null ? "an earlier run" : formatInstant(view.ciFirstValidatedAt)}`);
990
1183
  emit(options, {
991
1184
  step: "ci",
992
1185
  status: "connected",
@@ -1228,7 +1421,7 @@ async function stepPromise(options, session, view, published) {
1228
1421
  const state = await readState(options, session);
1229
1422
  const current = state?.repositories.find((repository) => repository.id === view.id) ?? view;
1230
1423
  if (current.firstPromiseId !== null) {
1231
- const promiseUrl = `${options.controlPlane}/promises/${current.firstPromiseId}`;
1424
+ const promiseUrl = reviewLink(options.controlPlane, `promises/${encodeURIComponent(current.firstPromiseId)}`, session.workspaceId);
1232
1425
  say(options, "Step 5 of 5 A named person agreed to the first promise");
1233
1426
  say(options, ` ${promiseUrl}`);
1234
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.");
@@ -1241,7 +1434,7 @@ async function stepPromise(options, session, view, published) {
1241
1434
  return;
1242
1435
  }
1243
1436
  if (current.latestCandidateId !== null) {
1244
- const reviewUrl = `${options.controlPlane}/candidates/${current.latestCandidateId}`;
1437
+ const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId, session.workspaceId);
1245
1438
  say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
1246
1439
  say(options, ` Review it: ${reviewUrl}`);
1247
1440
  say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
@@ -1301,10 +1494,12 @@ function printReceipts(options, session, state, view) {
1301
1494
  // promise the receipt below it said already stood agreed.
1302
1495
  const promiseId = current?.firstPromiseId ?? null;
1303
1496
  const candidateId = current?.latestCandidateId ?? null;
1304
- 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);
1305
1500
  const reviewUrl = promiseId !== null || candidateId === null
1306
1501
  ? undefined
1307
- : `${options.controlPlane}/candidates/${candidateId}`;
1502
+ : proposalReviewLink(options.controlPlane, candidateId, state.workspace.id);
1308
1503
  const promiseLink = promiseUrl ?? reviewUrl;
1309
1504
  const promiseSentence = promiseId !== null
1310
1505
  ? "Agreed. A named person agreed to this repository's first promise."
@@ -1402,11 +1597,14 @@ async function runRefresh(options) {
1402
1597
  }
1403
1598
  const existing = currentEntry(readMcpConfig(root));
1404
1599
  let stored;
1600
+ let storedName;
1405
1601
  try {
1406
1602
  const credentials = readCredentials(options.environment);
1407
1603
  const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHint(options.cwd));
1408
- if (selection.kind === "agent")
1604
+ if (selection.kind === "agent") {
1409
1605
  stored = selection.agent.repositoryId;
1606
+ storedName = selection.agent.repository;
1607
+ }
1410
1608
  }
1411
1609
  catch {
1412
1610
  // A store this machine cannot read is not a reason to refuse the repair:
@@ -1433,6 +1631,12 @@ async function runRefresh(options) {
1433
1631
  say(options, conventions.reason);
1434
1632
  return failRefresh(options, "conventions_refused", conventions.reason);
1435
1633
  }
1634
+ // The third stale thing, repaired by the same command and for the same
1635
+ // reason. A desktop entry naming an interpreter that a node upgrade moved is
1636
+ // a chat client that silently has no Balladeer in it, and nothing on screen
1637
+ // says so. This rewrites it to the interpreter and entry point running right
1638
+ // now, and leaves every other server in that file alone.
1639
+ const desktop = connectClaudeDesktop(options, repositoryId, storedName ?? repositoryHint(options.cwd));
1436
1640
  const files = [];
1437
1641
  if (merged.changed)
1438
1642
  files.push(MCP_CONFIG_FILE);
@@ -1446,21 +1650,27 @@ async function runRefresh(options) {
1446
1650
  ? ""
1447
1651
  : ` (it was v${conventions.previousVersion})`}. Everything outside the markers is untouched.`
1448
1652
  : `${conventions.file} already carries conventions v${conventions.version}; I left it alone.`);
1449
- if (files.length === 0) {
1653
+ // Said before the closing line, because it names a file outside the working
1654
+ // tree and the closing line is about what to commit. The desktop entry is
1655
+ // never a change anybody commits: it lives in this person's home directory.
1656
+ for (const line of desktop.lines)
1657
+ say(options, line.replace(/^ {2}/, ""));
1658
+ if (files.length === 0 && desktop.json?.status !== "connected") {
1450
1659
  say(options, "Nothing to change: this repository is already on the current form.");
1451
1660
  }
1452
- else {
1661
+ else if (files.length > 0) {
1453
1662
  say(options, `Commit ${files.join(" and ")} when you are ready.`);
1454
1663
  }
1455
1664
  emit(options, {
1456
1665
  step: "refresh",
1457
- status: files.length === 0 ? "current" : "updated",
1666
+ status: files.length === 0 && desktop.json?.status !== "connected" ? "current" : "updated",
1458
1667
  repositoryId,
1459
1668
  files,
1460
1669
  conventionsVersion: conventions.version,
1461
1670
  ...(conventions.previousVersion === undefined
1462
1671
  ? {}
1463
1672
  : { previousConventionsVersion: conventions.previousVersion }),
1673
+ ...(desktop.json === undefined ? {} : { claudeDesktop: desktop.json }),
1464
1674
  });
1465
1675
  return 0;
1466
1676
  }
@@ -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.