balladeer 1.0.0 → 1.0.1

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