@catalyst-cloud/cli 0.16.28 → 0.16.30

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 (38) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +3 -3
  3. package/dist/args.js +15 -0
  4. package/dist/capabilities.js +2 -0
  5. package/dist/cli-version.js +41 -0
  6. package/dist/cli.js +23 -0
  7. package/dist/lease.js +302 -0
  8. package/dist/onboard-existing.js +12 -3
  9. package/dist/onboard-next.js +39 -0
  10. package/dist/onboard-projects.js +3 -3
  11. package/dist/onboard-repositories.js +10 -3
  12. package/dist/onboard-runner-disk.js +345 -0
  13. package/dist/onboard-runner.js +52 -0
  14. package/dist/onboard-ui.js +5 -1
  15. package/dist/onboard.js +11 -0
  16. package/dist/ready.js +47 -1
  17. package/dist/runner.js +19 -2
  18. package/dist/setup-onboard-copy.js +41 -2
  19. package/dist/watch/consumer.js +1 -0
  20. package/dist/watch.js +2 -1
  21. package/package.json +1 -1
  22. package/skills/catalyst-github/SKILL.md +1 -1
  23. package/skills/catalyst-linear/SKILL.md +1 -1
  24. package/skills/catalyst-onboard/SKILL.md +1 -1
  25. package/skills/catalyst-onboard/references/reading-ready.md +1 -0
  26. package/skills/run-this-project/SKILL.md +1 -1
  27. package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
  28. package/skills/unstick/SKILL.md +1 -1
  29. package/skills/what-needs-me/SKILL.md +1 -1
  30. package/skills/what-this-repo-needs/SKILL.md +1 -1
  31. package/skills/whats-happening/SKILL.md +1 -1
  32. package/skills/whats-happening/references/what-runs-next.md +1 -1
  33. package/skills/whats-happening/references/why-is-it-stuck.md +1 -1
  34. package/vendor/self-host/darwin-thoughts-custody/manifest.json +2 -2
  35. package/vendor/self-host/darwin-thoughts-custody/verifier.mjs +90 -31
  36. package/vendor/self-host/host-disk-sample.py +57 -0
  37. package/vendor/self-host/launchd/dev.catalystcloud.host-disk.plist +18 -0
  38. package/vendor/self-host/provenance.json +6 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.30
4
+
5
+ An agent seat can now hold a ticket while it works on it. `catalyst lease take <ticket> --agent <seat>` takes the ticket for that seat for a set time (`--ttl <minutes>`). `catalyst lease renew <ticket>` keeps it, and each renewal names one piece of progress: a commit (`--commit`), an artifact (`--artifact`), a finding (`--finding`) or a pull request (`--pr`). `catalyst lease release <ticket>` hands it back, and `catalyst lease show <ticket>` prints who holds it. A refused renewal, for example one that names no new progress, prints the reason and exits 1. A renewal or release refused because the lease already lapsed, or because another seat took the ticket, forgets the lease this machine stored. The machine stores only the seat name and the lease generation, in a file only you can read. New: `catalyst work available` lists, per team, the tickets nobody holds, with the last holder and their progress, and the open pull requests no live lease covers, drafts included. Both commands use your login. Setup run unattended now says what it skipped and names the exact flags each skipped choice needs. The skill references now say how to see whether a repository is paused, and who can pause or resume it.
6
+
7
+ ## 0.16.29
8
+
9
+ The CLI now tells Catalyst Cloud which release you have installed: each request to Catalyst Cloud carries the installed version, and so does `catalyst watch` when it connects. Catalyst Cloud can then tell an out-of-date CLI from a current one. Nothing about your requests changes otherwise. On a Mac self-hosted machine, setup now installs a small agent that measures the Mac's disk, so the machine can take work instead of showing zero free slots. When a machine still shows zero free slots, setup and `catalyst ready` now say why: its disk has not been measured yet, or its free space is below the floor (25 GiB by default), with the numbers and the fix. An updated disk sampler also takes effect straight away: setup reloads the running disk agent whenever the sampler script changes, not only when its schedule file does.
10
+
3
11
  ## 0.16.28
4
12
 
5
13
  `catalyst events wait-for` can now wait on a repository (`--repo owner/name`), a branch (`--ref owner/name:branch`), a pull request (`--pr owner/name#n`), a work session (`--session KEY-123/phase#n`), events addressed to you (`--to me`) or a family of event types (`--type pr.*`). An agent can wait hours for a review or a conflict on its own branch: the wait holds one connection, prints each matching event as one JSON line, and after a dropped connection picks up where it left off without missing or repeating an event. Two new exit codes say why a wait ended without a match. 2 means the cloud refused the wait, for example on a ticket outside your teams, or the wait failed. 3 means `--from-cache` found no local event cache. A wait still exits 1 on a timeout and 4 on an outage, as before, and on a timeout it names the `--after` to resume from. New: `catalyst messages read --ticket KEY-123` prints the messages sent to a ticket on your teams, with each sender and time.
package/README.md CHANGED
@@ -164,7 +164,7 @@ Before any network call, the run checks that every input is present. Each missin
164
164
 
165
165
  With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
166
166
 
167
- With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0.
167
+ With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0. On a Mac, setup also installs the `dev.catalystcloud.host-disk` LaunchAgent, which measures the Mac's free disk space every 10 seconds, because Docker's virtual machine cannot see it. It needs Python 3 from Apple's Command Line Tools or Homebrew. When the machine takes no work because the disk Docker uses has less than 25 GiB free (`CATALYST_HOST_FREE_FLOOR_GIB` changes the amount) or has no fresh measurement, exit 11 says which, with the fix, and `catalyst ready` fails its `runner:disk` check the same way. If setup cannot tell which folder holds your Docker engine's disk image, set `CATALYST_HOST_DISK_PATH` to that folder.
168
168
 
169
169
  ### Stopping or removing this machine's runner
170
170
 
@@ -175,9 +175,9 @@ Run these on the machine itself:
175
175
  | `catalyst runner stop` | The machine takes no new work. Work it already started finishes or reaches its deadline. Its registration, settings and checkouts are kept, and `catalyst hosts` shows it as `stopped`. |
176
176
  | `catalyst runner start` | Starts it again with the same registration. |
177
177
  | `catalyst runner status` | Says whether it is running, stopped or not installed. |
178
- | `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
178
+ | `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders, and the disk-measuring LaunchAgent setup installed. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
179
179
 
180
- Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label or a changed unit file, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
180
+ Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label, a changed unit file or a disk-measuring LaunchAgent that writes somewhere else, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
181
181
 
182
182
  With `--json`, stdout carries exactly one document: the onboarding receipt plus a `headless` block (`schema: "catalyst-onboard-headless/1"`). The block holds the resolved `inputs` (the key appears only as its source: `env`, `file` or `saved`), and lists of items under `missing`, `refused`, `failed`, `deferred` and `warnings`. Each item has `id`, `kind` (`input`, `grant` or `step`), `reason` and a one-line `text`, plus `flag`, `env` and `url` where they apply. Everything else goes to stderr.
183
183
 
package/dist/args.js CHANGED
@@ -508,6 +508,19 @@ export const FLAG_TABLES = {
508
508
  help: "with --remove: answer the question yes (a run with no terminal only reports otherwise)",
509
509
  },
510
510
  },
511
+ lease: {
512
+ agent: { value: true, help: "the seat's name, 1 to 40 of a-z, 0-9 and - (take: required; renew, release: when this machine holds the ticket for several seats)" },
513
+ ttl: { value: true, help: "take, renew: minutes until the lease lapses without a renewal (the cloud keeps it between 5 and 60; default 30)" },
514
+ commit: { value: true, help: "renew: the full 40-character sha of a commit you pushed" },
515
+ artifact: { value: true, help: "renew: a thoughts path or check-run id you wrote" },
516
+ finding: { value: true, help: "renew: a comment or review id you posted" },
517
+ pr: { value: true, help: "renew: the number of a pull request you opened" },
518
+ note: { value: true, help: "renew: a few words about the progress (not part of what counts as new)" },
519
+ generation: { value: true, help: "renew, release: the generation the take printed (default: the one this machine stored)" },
520
+ },
521
+ work: {
522
+ team: { value: true, help: "available: one team's key (default: every team in the workspace contract)" },
523
+ },
511
524
  repo: {
512
525
  reason: { value: true, help: "pause: required; resume: optional; 1 to 500 characters after trimming (owner or admin)" },
513
526
  write: {
@@ -594,6 +607,8 @@ export const VERB_USAGE = {
594
607
  messages: "messages read --ticket CTC-N [--since SEQUENCE] [--json]",
595
608
  capabilities: "capabilities [--json]",
596
609
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
610
+ lease: "lease <take <ticket> --agent <seat> [--ttl <minutes>] | renew <ticket> (--commit <sha>|--artifact <path or id>|--finding <id>|--pr <number>) [--note <text>] [--agent <seat>] [--generation <n>] | release <ticket> [--agent <seat>] [--generation <n>] | show <ticket>> [--json] (a seat's ticket lease: take, renew with new progress, release, read; needs login)",
611
+ work: "work available [--team K] [--json] (per team: tickets nobody holds, with the last holder and progress, and open pull requests no live lease covers, drafts included)",
597
612
  repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
598
613
  legacy: "legacy [--remove [--data] [--yes]] [--json]",
599
614
  connections: "connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
@@ -32,6 +32,8 @@ export const CAPABILITIES = [
32
32
  { verb: "project wip-limit set", does: "set a project's new-start WIP limit (0 to 9999, or back to the workspace default)", needs: "admin", routes: [agent("POST", "team-wip-limit")], since: "0.13.1" },
33
33
  { verb: "repo status", does: "list visible repositories with their state, pause details and projects", needs: "member", routes: [agent("GET", "repos")], since: "0.16.27" },
34
34
  { verb: "repo pause|resume", does: "pause or resume a repository as a workspace owner or admin", needs: "admin", routes: [agent("GET", "repos"), agent("POST", "repos/pause"), agent("POST", "repos/resume")], since: "0.16.27" },
35
+ { verb: "lease take|renew|release|show", does: "hold the ticket a seat works through a ticket lease that lapses without new progress, instead of a label", needs: "member", routes: [agent("POST", "ticket-lease/take"), agent("POST", "ticket-lease/renew"), agent("POST", "ticket-lease/release"), agent("GET", "ticket-lease")], since: "0.16.30" },
36
+ { verb: "work available", does: "list tickets nobody holds, with the last holder and progress, and open pull requests no lease covers", needs: "member", routes: [], since: "0.16.30" },
35
37
  { verb: "repo agents-block", does: "add or refresh the one Catalyst block in a checkout's AGENTS.md, in the working tree, for the person to commit and open as a pull request", needs: "member", routes: [], since: "0.13.1" },
36
38
  { verb: "repo agent-setup", does: "read a checkout's agent setup (AGENTS.md, CLAUDE.md, .agents and .claude skills and rules) and, on request, make it portable in the working tree", needs: "member", routes: [], since: "0.13.1" },
37
39
  { verb: "env shell", does: "open this repository's environment on this computer: Catalyst's image, the declared toolchain and variables (needs Docker; no login for Catalyst's image)", needs: "member", routes: [], since: "0.16.20" },
@@ -0,0 +1,41 @@
1
+ const HEADER = "x-catalyst-cli-version";
2
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
3
+ /** Public package metadata on the selected cloud API transport. All fetch semantics stay intact. */
4
+ export function reportCliVersion(transport, version, origins) {
5
+ if (version.length > 80 || !VERSION.test(version))
6
+ return transport;
7
+ return (input, init) => {
8
+ let url;
9
+ try {
10
+ url = new URL(input instanceof Request ? input.url : String(input));
11
+ const selected = origins().some(origin => {
12
+ try {
13
+ return new URL(origin).origin === url.origin;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ });
19
+ if (!selected || !(url.pathname.startsWith("/api/v1/") || ["/snapshot", "/changes", "/events"].includes(url.pathname)))
20
+ return transport(input, init);
21
+ }
22
+ catch {
23
+ return transport(input, init);
24
+ }
25
+ return transport(input, { ...init, headers: withVersion(init?.headers ?? (input instanceof Request ? input.headers : undefined), version) });
26
+ };
27
+ }
28
+ function withVersion(headers, version) {
29
+ // A Headers from another realm or library fails instanceof, and Object.entries would read it as
30
+ // empty and drop every header the caller sent. Anything iterable that is not an array is copied.
31
+ if (headers instanceof Headers || (!Array.isArray(headers) && typeof headers?.forEach === "function")) {
32
+ const copy = new Headers(headers);
33
+ copy.set(HEADER, version);
34
+ return copy;
35
+ }
36
+ if (Array.isArray(headers))
37
+ return [...headers.filter(([name]) => name.toLowerCase() !== HEADER), [HEADER, version]];
38
+ const record = Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => name.toLowerCase() !== HEADER));
39
+ record[HEADER] = version;
40
+ return record;
41
+ }
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { existsSync, openSync, closeSync } from "node:fs";
8
8
  import { ReadStream as TerminalInput } from "node:tty";
9
9
  import { Writable } from "node:stream";
10
10
  import { parseArgs, positionals, verbHelp } from "./args.js";
11
+ import { reportCliVersion } from "./cli-version.js";
11
12
  import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
12
13
  import { loadContract, pickPath } from "./contract.js";
13
14
  import { CliError, MeError, UsageError } from "./errors.js";
@@ -45,6 +46,7 @@ import { cmdTeam } from "./team.js";
45
46
  import { cmdCapabilities } from "./capabilities.js";
46
47
  import { cmdProject } from "./project.js";
47
48
  import { cmdRepo } from "./repo.js";
49
+ import { cmdLease, cmdWork } from "./lease.js";
48
50
  import { cmdLegacy } from "./legacy.js";
49
51
  import { cmdIdentity } from "./identity.js";
50
52
  import { cmdMessages } from "./messages.js";
@@ -112,6 +114,8 @@ export function usageText() {
112
114
  " catalyst repo pause <owner/name> --reason <text> | repo resume <owner/name> [--reason <text>] (owner or admin; reason: 1 to 500 trimmed characters)",
113
115
  " catalyst repo status [--json] (visible workspace repositories and their paused state; needs login)",
114
116
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
117
+ " catalyst lease <take <ticket> --agent <seat> [--ttl <minutes>]|renew <ticket> --commit|--artifact|--finding|--pr <ref> [--note <text>]|release <ticket>|show <ticket>> [--json] (a seat's ticket lease; needs login)",
118
+ " catalyst work available [--team K] [--json] (tickets nobody holds and open pull requests no lease covers)",
115
119
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
116
120
  " catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
117
121
  " catalyst messages read --ticket CTC-N [--since SEQUENCE] [--json] (messages sent to a ticket on your teams)",
@@ -280,6 +284,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
280
284
  command: args.command,
281
285
  version: manifest.version,
282
286
  });
287
+ ctx = {
288
+ ...ctx,
289
+ fetch: reportCliVersion(ctx.fetch, manifest.version, () => {
290
+ let configured;
291
+ try {
292
+ configured = loadConfig(ctx.home)?.baseUrl;
293
+ }
294
+ catch {
295
+ // The command reports its existing config error.
296
+ }
297
+ return [args.baseUrl, ctx.env.CATALYST_CLOUD_BASE_URL, configured, DEFAULT_BASE_URL].filter((origin) => typeof origin === "string");
298
+ }),
299
+ };
283
300
  try {
284
301
  if (args.command !== "onboard") {
285
302
  migrateLegacyCliPath(ctx);
@@ -534,6 +551,10 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
534
551
  return await cmdProject(args, ctx);
535
552
  case "repo":
536
553
  return await cmdRepo(args, ctx);
554
+ case "lease":
555
+ return await cmdLease(args, ctx);
556
+ case "work":
557
+ return await cmdWork(args, ctx);
537
558
  case "legacy":
538
559
  return await cmdLegacy(args, ctx, { localSyncSelected: savedLocalSync(ctx), ...deps.legacy });
539
560
  case "identity":
@@ -610,6 +631,8 @@ const VERB_HELP_KNOWN = Object.fromEntries([
610
631
  "capabilities",
611
632
  "project",
612
633
  "repo",
634
+ "lease",
635
+ "work",
613
636
  "legacy",
614
637
  "onboard",
615
638
  ].map((v) => [v, true]));
package/dist/lease.js ADDED
@@ -0,0 +1,302 @@
1
+ // lease.ts — `catalyst lease` and `catalyst work available` (CTC-6400): a seat or one-shot session
2
+ // holds the ticket it works through a ticket lease, never a label, and sees what work is free.
3
+ //
4
+ // lease take <TICKET> --agent <name> [--ttl <minutes>] POST /api/v1/agent/ticket-lease/take
5
+ // lease renew <TICKET> --commit|--artifact|--finding|--pr <ref> [--note <text>]
6
+ // POST /api/v1/agent/ticket-lease/renew
7
+ // lease release <TICKET> POST /api/v1/agent/ticket-lease/release
8
+ // lease show <TICKET> GET /api/v1/agent/ticket-lease?ticket=
9
+ // work available [--team K] GET /api/v1/work-eligibility?team=
10
+ //
11
+ // ⛔ THE KEY IS THE CREDENTIAL. The cloud mints no lease credential (CTC-6398): it derives the holder
12
+ // `seat:<keyId>:<agent>` from the login on every call. What this machine keeps per lease is only what
13
+ // the next call must name: the seat name and the generation the take returned, in a 0600 file under
14
+ // the config directory. Renew and release read it, so a seat names the ticket and its progress.
15
+ //
16
+ // ⭐ A REFUSAL IS A 200. The store answers `outcome: "refused"` with a reason (`no_new_progress`,
17
+ // `ticket_lease_held`, `not_holder`, …); this module prints it and exits 1, so a script can stop on
18
+ // it. A malformed request (400), a seat that does not reach the ticket's team (403) and an exhausted
19
+ // budget (429) exit 1 the same way, with the server's reason.
20
+ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, chmodSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { flagString, positionals } from "./args.js";
23
+ import { CONFIG_MODE, configDirFor, requireConfig } from "./config.js";
24
+ import { loadContract } from "./contract.js";
25
+ import { UsageError } from "./errors.js";
26
+ import { apiClient } from "./transport.js";
27
+ const ROUTE = "/api/v1/agent/ticket-lease";
28
+ const TICKET = /^[A-Za-z][A-Za-z0-9]*-[1-9][0-9]*$/;
29
+ const AGENT = /^[a-z0-9-]{1,40}$/;
30
+ const ACCEPT = [400, 403, 404, 409, 429];
31
+ function object(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function text(value) {
35
+ return typeof value === "string" && value.trim().length > 0;
36
+ }
37
+ function leaseRow(value) {
38
+ return object(value) && typeof value.generation === "number" && typeof value.deadlineMs === "number";
39
+ }
40
+ function leaseDir(ctx) {
41
+ return join(configDirFor(ctx.home), "leases");
42
+ }
43
+ function leasePath(ctx, ticket, agent) {
44
+ return join(leaseDir(ctx), `${ticket}--${agent}.json`);
45
+ }
46
+ function saveLease(ctx, lease) {
47
+ mkdirSync(leaseDir(ctx), { recursive: true });
48
+ const path = leasePath(ctx, lease.ticket, lease.agent);
49
+ writeFileSync(path, JSON.stringify(lease) + "\n", { mode: CONFIG_MODE });
50
+ chmodSync(path, CONFIG_MODE);
51
+ }
52
+ function forgetLease(ctx, ticket, agent) {
53
+ rmSync(leasePath(ctx, ticket, agent), { force: true });
54
+ }
55
+ /** Refusals that mean this seat's lease is gone: it lapsed, or a later take replaced it. */
56
+ const GONE = new Set(["expired", "not_holder", "stale_generation"]);
57
+ /** Forget a stored lease the cloud says this seat no longer holds, so a later seat on the same
58
+ * ticket is not asked to name one with --agent. The exit stays 1: the renew or release did not happen. */
59
+ function forgetIfGone(ctx, ticket, agent, body) {
60
+ if (!object(body) || !object(body.refusal) || !GONE.has(String(body.refusal.reason)))
61
+ return;
62
+ forgetLease(ctx, ticket, agent);
63
+ ctx.stderr(`[catalyst] ${agent} no longer holds ${ticket}; forgot its stored lease`);
64
+ }
65
+ /** The leases this machine holds on a ticket, one per seat that took it. */
66
+ function storedLeases(ctx, ticket) {
67
+ let names;
68
+ try {
69
+ names = readdirSync(leaseDir(ctx));
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ const found = [];
75
+ for (const name of names.filter((n) => n.startsWith(`${ticket}--`) && n.endsWith(".json"))) {
76
+ try {
77
+ const value = JSON.parse(readFileSync(join(leaseDir(ctx), name), "utf8"));
78
+ if (object(value) && value.ticket === ticket && text(value.agent) && typeof value.generation === "number")
79
+ found.push({ ticket, agent: value.agent, generation: value.generation, holder: text(value.holder) ? value.holder : null });
80
+ }
81
+ catch {
82
+ // An unreadable file is not a lease this machine can name; --agent and --generation still can.
83
+ }
84
+ }
85
+ return found;
86
+ }
87
+ function parseTicket(raw, verb) {
88
+ if (!raw || !TICKET.test(raw))
89
+ throw new UsageError(`lease ${verb} needs one ticket such as ENG-123`);
90
+ return raw.toUpperCase();
91
+ }
92
+ function parseAgent(raw) {
93
+ if (raw === undefined)
94
+ return undefined;
95
+ if (!AGENT.test(raw))
96
+ throw new UsageError("--agent is the seat's name: 1 to 40 of a-z, 0-9 and -");
97
+ return raw;
98
+ }
99
+ /** The seat and generation a renew or release names: the flags, else the one lease this machine
100
+ * stored for the ticket. Two stored seats need --agent to choose. */
101
+ function heldLease(args, ctx, ticket) {
102
+ const agent = parseAgent(flagString(args, "agent"));
103
+ const rawGeneration = flagString(args, "generation");
104
+ let generation;
105
+ if (rawGeneration !== undefined) {
106
+ generation = Number(rawGeneration);
107
+ if (!Number.isSafeInteger(generation) || generation < 1)
108
+ throw new UsageError("--generation is the positive integer the take printed");
109
+ }
110
+ const stored = storedLeases(ctx, ticket).filter((s) => agent === undefined || s.agent === agent);
111
+ if (agent !== undefined && generation !== undefined)
112
+ return { agent, generation };
113
+ if (stored.length === 1)
114
+ return { agent: stored[0].agent, generation: generation ?? stored[0].generation };
115
+ if (stored.length > 1)
116
+ throw new UsageError(`this machine holds ${ticket} for ${stored.map((s) => s.agent).join(", ")}; name one with --agent`);
117
+ throw new UsageError(`this machine holds no lease on ${ticket}${agent ? ` for ${agent}` : ""}; take it first, or pass --agent and --generation`);
118
+ }
119
+ /** The one progress fact a renewal asserts, from exactly one of the four flags. */
120
+ function parseProgress(args) {
121
+ const given = ["commit", "artifact", "finding", "pr"]
122
+ .map((kind) => ({ kind, ref: flagString(args, kind) }))
123
+ .filter((p) => p.ref !== undefined);
124
+ if (given.length !== 1)
125
+ throw new UsageError("lease renew needs exactly one of --commit <sha>, --artifact <path or id>, --finding <id> or --pr <number>");
126
+ const { kind } = given[0];
127
+ const ref = given[0].ref.trim();
128
+ if (kind === "commit" && !/^[0-9a-f]{40}$/.test(ref))
129
+ throw new UsageError("--commit is the full 40-character lowercase sha");
130
+ if (kind === "pr" && !/^[1-9][0-9]{0,9}$/.test(ref))
131
+ throw new UsageError("--pr is the pull request's number");
132
+ if ((kind === "artifact" || kind === "finding") && (ref === "" || /\s/.test(ref)))
133
+ throw new UsageError(`--${kind} is one token with no spaces`);
134
+ const note = flagString(args, "note");
135
+ return { kind, ref, ...(note !== undefined && note.trim() !== "" ? { note } : {}) };
136
+ }
137
+ function parseTtlMs(args) {
138
+ const raw = flagString(args, "ttl");
139
+ if (raw === undefined)
140
+ return {};
141
+ const minutes = Number(raw);
142
+ if (!Number.isFinite(minutes) || minutes <= 0)
143
+ throw new UsageError("--ttl is a number of minutes (the cloud keeps it between 5 and 60)");
144
+ return { ttlMs: Math.round(minutes * 60_000) };
145
+ }
146
+ function when(ms) {
147
+ return new Date(ms).toISOString();
148
+ }
149
+ function detail(body) {
150
+ if (!object(body))
151
+ return "";
152
+ const parts = [body.reason, body.error, body.message].filter(text);
153
+ return parts.length ? `: ${[...new Set(parts)].join(": ")}` : "";
154
+ }
155
+ /** Print a refusal or an HTTP failure and return the exit code 1. */
156
+ function refuse(args, ctx, status, body, verb) {
157
+ if (args.json) {
158
+ ctx.stdout(JSON.stringify(object(body) ? { status, ...body } : { status }));
159
+ return 1;
160
+ }
161
+ if (status === 200 && object(body) && object(body.refusal)) {
162
+ const r = body.refusal;
163
+ const current = leaseRow(r.current) ? r.current : null;
164
+ const parts = [`refused: ${text(r.reason) ? r.reason : "unknown"}`];
165
+ if (current?.holder)
166
+ parts.push(`held by ${current.holder} until ${when(current.deadlineMs)}`);
167
+ if (typeof r.retryAtMs === "number")
168
+ parts.push(`retry at ${when(r.retryAtMs)}`);
169
+ ctx.stdout(parts.join("; "));
170
+ return 1;
171
+ }
172
+ const meaning = status === 404 ? `this Catalyst Cloud does not serve lease ${verb} yet (needs a newer cloud)`
173
+ : status === 403 ? "your login does not reach this ticket's team"
174
+ : status === 429 ? "your write budget is spent for now"
175
+ : `lease ${verb} failed`;
176
+ ctx.stdout(`refused (${status}): ${meaning}${detail(body)}`);
177
+ return 1;
178
+ }
179
+ function printLease(ctx, ticket, lease) {
180
+ ctx.stdout(`${ticket}: held by ${lease.holder ?? "nobody"} until ${when(lease.deadlineMs)}, generation ${lease.generation}`);
181
+ if (text(lease.lastProgress))
182
+ ctx.stdout(` last progress: ${lease.lastProgress}`);
183
+ }
184
+ export async function cmdLease(args, ctx) {
185
+ const [action, rawTicket, ...extra] = positionals(args);
186
+ if (action !== "take" && action !== "renew" && action !== "release" && action !== "show")
187
+ throw new UsageError("lease needs take, renew, release or show");
188
+ if (extra.length)
189
+ throw new UsageError(`lease ${action} takes one ticket`);
190
+ const ticket = parseTicket(rawTicket, action);
191
+ const client = apiClient(requireConfig(ctx), ctx);
192
+ if (action === "show") {
193
+ const res = await client.getJson(ROUTE, { query: { ticket }, accept: ACCEPT, parseAccepted: true });
194
+ if (res.status >= 400)
195
+ return refuse(args, ctx, res.status, res.body, action);
196
+ if (args.json) {
197
+ ctx.stdout(JSON.stringify(res.body));
198
+ return 0;
199
+ }
200
+ const lease = object(res.body) && leaseRow(res.body.lease) ? res.body.lease : null;
201
+ if (lease === null)
202
+ ctx.stdout(`${ticket}: no ticket lease`);
203
+ else if (lease.live === false) {
204
+ ctx.stdout(`${ticket}: free; last held by ${lease.lastHolder ?? lease.holder ?? "nobody"}, generation ${lease.generation}`);
205
+ if (text(lease.lastProgress))
206
+ ctx.stdout(` last progress: ${lease.lastProgress}`);
207
+ }
208
+ else
209
+ printLease(ctx, ticket, lease);
210
+ return 0;
211
+ }
212
+ if (action === "take") {
213
+ const agent = parseAgent(flagString(args, "agent"));
214
+ if (agent === undefined)
215
+ throw new UsageError("lease take needs --agent <seat name>");
216
+ const res = await client.postJson(`${ROUTE}/take`, { ticket, agent, ...parseTtlMs(args) }, { accept: ACCEPT });
217
+ if (res.status !== 200 || !object(res.body) || res.body.outcome !== "taken" || !leaseRow(res.body.lease))
218
+ return refuse(args, ctx, res.status, res.body, action);
219
+ const lease = res.body.lease;
220
+ saveLease(ctx, { ticket, agent, generation: lease.generation, holder: lease.holder });
221
+ if (args.json)
222
+ ctx.stdout(JSON.stringify(res.body));
223
+ else
224
+ printLease(ctx, ticket, lease);
225
+ return 0;
226
+ }
227
+ if (action === "renew") {
228
+ const progress = parseProgress(args);
229
+ const held = heldLease(args, ctx, ticket);
230
+ const res = await client.postJson(`${ROUTE}/renew`, { ticket, ...held, progress, ...parseTtlMs(args) }, { accept: ACCEPT });
231
+ if (res.status !== 200 || !object(res.body) || res.body.outcome !== "renewed" || !leaseRow(res.body.lease)) {
232
+ forgetIfGone(ctx, ticket, held.agent, res.body);
233
+ return refuse(args, ctx, res.status, res.body, action);
234
+ }
235
+ if (args.json)
236
+ ctx.stdout(JSON.stringify(res.body));
237
+ else
238
+ printLease(ctx, ticket, res.body.lease);
239
+ return 0;
240
+ }
241
+ const held = heldLease(args, ctx, ticket);
242
+ const res = await client.postJson(`${ROUTE}/release`, { ticket, ...held }, { accept: ACCEPT });
243
+ if (res.status !== 200 || !object(res.body) || res.body.outcome !== "released") {
244
+ forgetIfGone(ctx, ticket, held.agent, res.body);
245
+ return refuse(args, ctx, res.status, res.body, action);
246
+ }
247
+ forgetLease(ctx, ticket, held.agent);
248
+ if (args.json)
249
+ ctx.stdout(JSON.stringify(res.body));
250
+ else
251
+ ctx.stdout(`${ticket}: released by ${held.agent}`);
252
+ return 0;
253
+ }
254
+ /** The free tickets in one team's rows: offered, or offered with no capable runner (a seat can still
255
+ * take it), with no live holder named on them. */
256
+ function freeTickets(rows) {
257
+ return rows.filter((r) => text(r.ticket) && (r.status === "offered" || r.status === "capability_mismatch") && !text(r.holder));
258
+ }
259
+ /** `work available`: per team, the tickets nobody holds (with the last holder and progress when a
260
+ * lease lapsed) and the open pull requests no live lease covers, drafts included. */
261
+ export async function cmdWork(args, ctx) {
262
+ const [action, ...extra] = positionals(args);
263
+ if (action !== "available" || extra.length)
264
+ throw new UsageError("work needs available");
265
+ const cfg = requireConfig(ctx);
266
+ const client = apiClient(cfg, ctx);
267
+ const named = flagString(args, "team");
268
+ let teams;
269
+ if (named !== undefined)
270
+ teams = [named.toUpperCase()];
271
+ else {
272
+ const { doc } = await loadContract(ctx, cfg);
273
+ teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
274
+ if (teams.length === 0)
275
+ throw new UsageError("work available needs --team: the workspace contract names no team");
276
+ }
277
+ const results = {};
278
+ for (const team of teams) {
279
+ const res = await client.getJson("/api/v1/work-eligibility", { query: { team } });
280
+ const rows = Array.isArray(res.body.eligibility?.rows) ? res.body.eligibility.rows : [];
281
+ const pulls = Array.isArray(res.body.openPulls) ? res.body.openPulls : [];
282
+ results[team] = {
283
+ tickets: freeTickets(rows),
284
+ unownedPulls: pulls.filter((p) => object(p) && typeof p.number === "number" && p.owner === "unowned"),
285
+ };
286
+ }
287
+ if (args.json) {
288
+ ctx.stdout(JSON.stringify(named !== undefined ? results[teams[0]] : results));
289
+ return 0;
290
+ }
291
+ for (const team of teams) {
292
+ const { tickets, unownedPulls } = results[team];
293
+ ctx.stdout(`${team}: ${tickets.length} free ${tickets.length === 1 ? "ticket" : "tickets"}, ${unownedPulls.length} unowned open ${unownedPulls.length === 1 ? "pull request" : "pull requests"}`);
294
+ for (const row of tickets) {
295
+ const last = text(row.lastHolder) ? `; last held by ${row.lastHolder}${text(row.lastProgress) ? `, last progress ${row.lastProgress}` : ""}` : "";
296
+ ctx.stdout(` ${row.ticket}${last}`);
297
+ }
298
+ for (const pull of unownedPulls)
299
+ ctx.stdout(` #${pull.number}${pull.draft ? " (draft)" : ""}${text(pull.ticket) ? ` ${pull.ticket}` : ""}: unowned${text(pull.title) ? `, ${pull.title}` : ""}`);
300
+ }
301
+ return 0;
302
+ }
@@ -7,6 +7,14 @@ const waiting = (reason) => ({
7
7
  state: "waiting",
8
8
  reason,
9
9
  });
10
+ /**
11
+ * CTC-5932: at most 20 choices, so the advice stays one readable line, and the full count, so the
12
+ * advice never reports the cap as the workspace's total.
13
+ */
14
+ export const choiceEvidence = (values) => {
15
+ const unique = [...new Set(values)].sort();
16
+ return { choices: unique.slice(0, 20).join(","), choiceTotal: unique.length };
17
+ };
10
18
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
11
19
  ? value
12
20
  : null;
@@ -175,7 +183,8 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
175
183
  evidence: { teamKey: uncertain.key },
176
184
  }
177
185
  : undefined;
178
- const stopTeam = (reason) => uncertainResult() ?? waiting(reason);
186
+ const stopTeam = (reason, teams) => uncertainResult() ??
187
+ (teams?.length ? { ...waiting(reason), evidence: choiceEvidence(teams.map((team) => team.key)) } : waiting(reason));
179
188
  const requested = (teams, journal) => {
180
189
  const explicit = args.flags.team;
181
190
  if (typeof explicit === "string") {
@@ -210,7 +219,7 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
210
219
  return stopTeam("team_selection_unverified");
211
220
  return choose
212
221
  ? (uncertainResult("pending") ?? { state: "pending" })
213
- : stopTeam("team_choice_required");
222
+ : stopTeam("team_choice_required", list.teams);
214
223
  }
215
224
  const result = await observe(ctx, team.id, signal);
216
225
  if (!result || result.checks.get("team_visible") !== "pass")
@@ -257,7 +266,7 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
257
266
  if ("reason" in list)
258
267
  return stopTeam(list.reason);
259
268
  if (!choose)
260
- return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty");
269
+ return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty", list.teams);
261
270
  let offer = nameNewTeam && typeof args.flags.team !== "string"
262
271
  ? await teamCreateOffer(ctx, journal, signal)
263
272
  : undefined;
@@ -3,6 +3,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
3
3
  import { INSTALL_COMMAND, loadConfig, normalizeBaseUrl } from "./config.js";
4
4
  import { githubInstallationPage } from "./onboard-permissions.js";
5
5
  import { valuesFacts, valuesFix } from "./ready-copy.js";
6
+ import { DEFAULT_HOST_FREE_FLOOR_GIB, isRunnerDiskUnmeasured, runnerDiskCopy } from "./onboard-runner-disk.js";
6
7
  /** What a person reads for a step that is not done. Each line ends in an action they can take. */
7
8
  const REASONS = {
8
9
  legacy_cleanup_failed: "An earlier Catalyst installation could not be removed. Run catalyst legacy --remove to see the exact item and its blocker, fix that blocker, then run catalyst setup again. Shared data folders are kept.",
@@ -158,6 +159,10 @@ const REASONS = {
158
159
  runner_compose_not_running: "The runner's supervisor is not running. Run docker compose -p catalyst-host logs supervisor to see why, then run catalyst onboard again.",
159
160
  runner_enrollment_unverified: "The runner started but has not enrolled yet. Run docker compose -p catalyst-host logs supervisor to see why, or run catalyst onboard again in a minute.",
160
161
  runner_needs_runner_flag: "This machine was set up to run Catalyst's work, but its runner is not running or not enrolled. To bring it up, run catalyst onboard --runner.",
162
+ runner_host_disk_sampler_python_missing: "Setup needs Python 3 to measure this Mac's disk for the runner. Install Apple's Command Line Tools with xcode-select --install, or python3 from Homebrew, then run catalyst setup --runner.",
163
+ runner_host_disk_location_unverified: "Setup could not tell which disk holds your Docker engine's virtual machine, so it cannot measure this Mac's free space for the runner. Set CATALYST_HOST_DISK_PATH to that folder (for a relocated Docker Desktop disk image, the folder chosen in Settings, Resources, Advanced), then run catalyst setup --runner.",
164
+ runner_host_disk_sampler_failed: "Setup could not start the dev.catalystcloud.host-disk LaunchAgent that measures this Mac's disk, so the runner will advertise zero free slots. Run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk, or user/$(id -u)/dev.catalystcloud.host-disk when no one is logged in at the console, to see why, then run catalyst setup --runner.",
165
+ runner_host_disk_sampler_not_repeating: "Setup loaded the dev.catalystcloud.host-disk LaunchAgent, but launchd did not keep it measuring this Mac's disk: it published no new sample within 25 seconds, so the runner will advertise zero free slots. launchd can hold back an agent loaded from an SSH session. Log in to this Mac's desktop, at the console or over Screen Sharing, and run catalyst setup --runner in Terminal there. To see what launchd did, run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk and read its runs and pended lines.",
161
166
  runner_capability_pending: "The runner enrolled but has not reported its capacity yet. Run catalyst onboard again in a minute.",
162
167
  runner_admission_unverified: "Setup could not read whether the team's pool admits runner hosts. Run catalyst onboard again to retry.",
163
168
  github_app_permissions_outdated: `The existing GitHub App installation needs updated permissions accepted. ${GITHUB_CONFIRM_ACCESS} An organization owner on GitHub opens the App's installation page, chooses Review permissions, then Accept new permissions. Run catalyst onboard afterward.`,
@@ -320,9 +325,23 @@ function firstTicketText(reason, key, stage) {
320
325
  }
321
326
  return undefined;
322
327
  }
328
+ /** CTC-5932: the choices a step recorded, for advice that names real flag values. */
329
+ function recordedChoices(step) {
330
+ const value = step.evidence?.choices;
331
+ return typeof value === "string" ? value.split(",").filter(Boolean) : [];
332
+ }
323
333
  /** One step's next action. `waitsFor` names the unfinished step a prerequisite wait is blocked on. */
324
334
  export function onboardReasonText(step, context = {}) {
325
335
  const reason = step.reason ?? "";
336
+ const choices = recordedChoices(step);
337
+ const recordedTotal = step.evidence?.choiceTotal;
338
+ const total = typeof recordedTotal === "number" && recordedTotal > choices.length ? recordedTotal : choices.length;
339
+ // The list is capped at 20, so a longer inventory says which part it shows.
340
+ const shown = total > choices.length ? `, the first ${choices.length} of them` : "";
341
+ if (reason === "team_choice_required" && choices.length)
342
+ return `This workspace has ${total} Linear ${total === 1 ? "team" : "teams"}${shown}: ${choices.join(", ")}. Add --team with one of them to the same command, for example --team ${choices[0]}.`;
343
+ if (reason === "repository_choice_required" && choices.length)
344
+ return `${total === 1 ? "One repository is" : `${total} repositories are`} available${shown}: ${choices.join(", ")}. Add --repo <owner/name> to the same command, for example --repo ${choices[0]}. Repeat --repo to choose more than one.`;
326
345
  if ([
327
346
  "workspace_approval_required",
328
347
  "personal_approval_required",
@@ -454,6 +473,26 @@ export function onboardReasonText(step, context = {}) {
454
473
  : "on the web app's Account API keys page";
455
474
  return `The runner is enrolled, but it takes no work without an organization key. A workspace owner or administrator runs catalyst onboard --runner on this machine; it mints the key and stores it for the runner. If their cloud cannot mint one yet, they create one with the mirror:read, mirror:write and mirror:feed scopes ${where}, save it in a file only they can read, and run catalyst onboard --runner with CATALYST_RUNNER_ORG_KEY_FILE set to that file.`;
456
475
  }
476
+ if (reason === "runner_host_disk_sampler_conflict") {
477
+ // Both settings are needed: a custom directory turns off setup's sampler, and the sample file
478
+ // name is then empty unless it is set too.
479
+ const safe = (value) => typeof value === "string" && /^\/[^\x00-\x1f'"]{0,500}$/.test(value) ? value : null;
480
+ const dir = safe(step.evidence?.sampleDir) ?? "<directory the other agent writes to>";
481
+ const file = safe(step.evidence?.sampleFile) ?? "/var/lib/catalyst/host-disk/<its file name>";
482
+ return `Another dev.catalystcloud.host-disk LaunchAgent on this Mac writes its disk sample somewhere else, so setup left it alone and the runner has no disk measurement. To use that agent's sample, set both CATALYST_HOST_DISK_SAMPLE_DIR=${dir} and CATALYST_HOST_DISK_SAMPLE_FILE=${file}, then run catalyst setup --runner; the directory alone leaves the runner without a sample file. Or remove the other agent: run launchctl bootout gui/$(id -u)/dev.catalystcloud.host-disk and launchctl bootout user/$(id -u)/dev.catalystcloud.host-disk (one of them reports it is not loaded), delete ~/Library/LaunchAgents/dev.catalystcloud.host-disk.plist, then run catalyst setup --runner.`;
483
+ }
484
+ if (reason === "runner_host_disk_below_floor" || reason === "runner_host_disk_unmeasured") {
485
+ const e = step.evidence ?? {};
486
+ const num = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
487
+ const floorGiB = num(e.floorGiB) ?? DEFAULT_HOST_FREE_FLOOR_GIB, availableGiB = num(e.availableGiB);
488
+ if (reason === "runner_host_disk_below_floor" && availableGiB === null)
489
+ return `The runner advertises zero free slots: the disk it measures has less than ${floorGiB} GiB free. Free disk space, then run catalyst ready to see how much is free.`;
490
+ const detail = typeof e.diskDetail === "string" && isRunnerDiskUnmeasured(e.diskDetail) ? e.diskDetail : "physical_host_disk_sample_required";
491
+ const copy = runnerDiskCopy(reason === "runner_host_disk_below_floor" && availableGiB !== null
492
+ ? { state: "below_floor", availableGiB, floorGiB }
493
+ : { state: "unmeasured", detail, floorGiB }, e.vm === true);
494
+ return `${copy.line} ${copy.fix}`;
495
+ }
457
496
  if (reason === "runner_host_not_ready") {
458
497
  const failing = step.evidence?.failing;
459
498
  const checks = typeof failing === "string" && /^[a-z0-9.,_-]{1,500}$/.test(failing)
@@ -1,7 +1,7 @@
1
1
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
2
2
  import { loadConfig, normalizeBaseUrl } from "./config.js";
3
3
  import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
- import { existingRepositoryAdapter, } from "./onboard-repositories.js";
4
+ import { existingRepositoryAdapter, repositoryChoiceWait, } from "./onboard-repositories.js";
5
5
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
6
6
  ? value
7
7
  : null;
@@ -211,7 +211,7 @@ export function firstProjectAdapters(args, input = {}) {
211
211
  : waiting("repository_selection_unverified");
212
212
  return input.chooseFirst && list.rows.length
213
213
  ? { state: "pending" }
214
- : waiting("repository_choice_required");
214
+ : repositoryChoiceWait(list.rows);
215
215
  },
216
216
  act: async (ctx, journal, signal) => {
217
217
  const live = await existing.check(ctx, journal, signal);
@@ -221,7 +221,7 @@ export function firstProjectAdapters(args, input = {}) {
221
221
  if ("reason" in list)
222
222
  return waiting(list.reason);
223
223
  if (!input.chooseFirst || !list.rows.length)
224
- return waiting("repository_choice_required");
224
+ return repositoryChoiceWait(list.rows);
225
225
  if (list.truncated)
226
226
  input.message?.("GitHub returned part of its repository list. Choose a displayed repository, or stop and adjust the App access before resuming.");
227
227
  let remove = () => { };