@catalyst-cloud/cli 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.2
4
+
5
+ - `catalyst setup` is the command to set up Catalyst. It runs the same steps as `catalyst onboard`, which still works.
6
+ - When a browser link times out, setup says so and asks whether you are ready to try again. Yes gives a new link; stopping pauses setup without moving on to steps that depend on it.
7
+ - The GitHub link opens GitHub's install page directly. An install request already waiting on an organization owner is not requested again, and setup still shows the link to install it yourself.
8
+ - Setup asks whether to run work on this machine only after Catalyst is installed on GitHub.
9
+ - Sign-in says which Catalyst workspace you joined, without internal account numbers. `catalyst ready` describes each team problem in plain words, with the page to fix it and who can fix it.
10
+
11
+ ## 0.15.1
12
+
13
+ - Runner setup accepts verified personal team admission before a repository has been mapped to that team. Older cloud servers keep their existing admission check.
14
+
3
15
  ## 0.15.0
4
16
 
5
17
  - Installation and onboarding share a plan and progress display. Browser approvals stay in the same flow, and resumed runs recheck saved choices against current access.
package/README.md CHANGED
@@ -4,13 +4,22 @@
4
4
 
5
5
  ## Install
6
6
 
7
- Run the installer, then type `/catalyst-onboard` in your coding agent:
7
+ Set up Catalyst Cloud and manage your account from the command line.
8
+
9
+ Check ticket progress and see which decisions need your attention.
10
+
11
+ Install: `npm install -g @catalyst-cloud/cli`
12
+
13
+ [CLI documentation](https://github.com/coalesce-labs/catalyst-cloud-skills#readme)
14
+
15
+ Run the installer, then run `catalyst onboard`:
8
16
 
9
17
  ```sh
10
18
  curl -fsSL https://staging.catalystcloud.dev/install.sh | sh
19
+ catalyst onboard
11
20
  ```
12
21
 
13
- The installer puts the CLI and both skill packs on this machine and connects it with one browser approval. It ends by printing the next step. Then open a new session of your coding agent in any directory and type `/catalyst-onboard` (`$catalyst-onboard` in Codex). The onboarding skill reads where this machine stands and walks you through the rest one step at a time. It asks before it writes anything to your machine. Everything else on this page is the reference it follows.
22
+ The installer puts the CLI and both skill packs on this machine and connects it with one browser approval. Run `catalyst onboard` to finish setup from the command line. You can also use the onboarding skill in your coding agent after that. Everything else on this page is the reference.
14
23
 
15
24
  This repository supplies skills for setting up and operating your Catalyst Cloud account. A coding workstation also uses [`coalesce-labs/catalyst-dev-skills`](https://github.com/coalesce-labs/catalyst-dev-skills) for research, planning, implementation, review, and shipping.
16
25
 
package/dist/args.js CHANGED
@@ -457,7 +457,7 @@ export const FLAG_TABLES = {
457
457
  },
458
458
  };
459
459
  export const VERB_USAGE = {
460
- setup: "setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
460
+ setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
461
461
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
462
462
  onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
463
463
  install: "install [--skills-dir <dir>] [--force]",
@@ -5,7 +5,9 @@ const agent = (method, name) => ({ method, path: `/api/v1/agent/${name}` });
5
5
  * the person, the workspace, the project, the repository, then work. A new verb is a new row here
6
6
  * in the same change, or `catalyst capabilities` lies to every guide that reads it. */
7
7
  export const CAPABILITIES = [
8
- { verb: "setup", does: "show install.sh's plan, progress and sign-in in this terminal, and ask its questions", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1 },
8
+ // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
9
+ // reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
10
+ { verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true },
9
11
  { verb: "onboard", does: "run and resume Catalyst setup with a saved step record", needs: "member", routes: [], since: "0.14.0", bootstrapHandoff: true, headless: true },
10
12
  { verb: "status", does: "say whether this machine is connected, as whom, and to which cloud", needs: "member", routes: [], since: "0.1.0" },
11
13
  { verb: "me", does: "read the person's seat, role and Linear identity", needs: "member", routes: [], since: "0.1.0" },
package/dist/cli.js CHANGED
@@ -69,7 +69,7 @@ const VERB_ALIASES = { join: "login" };
69
69
  const HUMAN_VERBS = new Set(["login", "install", "status", "notice"]);
70
70
  export function usageText() {
71
71
  return [
72
- `${PACKAGE_NAME} — the Catalyst Cloud customer CLI: connect to your tenant, read through the SDK, write through the agent proxy`,
72
+ `${PACKAGE_NAME} — the Catalyst Cloud customer CLI: connect to your Catalyst workspace, read through the SDK, write through the agent proxy`,
73
73
  "",
74
74
  "Usage:",
75
75
  " catalyst login [--base-url <url>] [--start-replica] (keyless: logs you in as yourself)",
@@ -96,7 +96,7 @@ export function usageText() {
96
96
  " catalyst var set <NAME> | var import <file> [--repo <owner/name>] [--names NAME[,NAME...]] (plain environment variables)",
97
97
  " catalyst team <list|check|map|adopt|migrate|checklist> ...",
98
98
  " catalyst capabilities [--json] (what this CLI can do, the role each verb needs, and whether this cloud serves it)",
99
- " catalyst project list [--json] (all tenant projects, including those with no stage mapping)",
99
+ " catalyst project list [--json] (all workspace projects, including those with no stage mapping)",
100
100
  " catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
101
101
  " 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)",
102
102
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
@@ -110,8 +110,8 @@ export function usageText() {
110
110
  "--key (or set CATALYST_CLOUD_TOKEN) to use a personal or account key instead. The base URL defaults",
111
111
  `to CATALYST_CLOUD_BASE_URL or ${DEFAULT_BASE_URL}.`,
112
112
  "",
113
- "login discovers your tenant from GET /api/v1/me, writes ~/.config/catalyst-cloud/customer.json",
114
- "(0600) holding your login session (or key) and this CLI's path, and caches the tenant contract",
113
+ "login finds your Catalyst workspace, writes ~/.config/catalyst-cloud/customer.json",
114
+ "(0600) holding your login session (or key) and this CLI's path, and caches the workspace contract",
115
115
  "beside it. A keyless session's short-lived token refreshes silently on every request. login does",
116
116
  "not install skills: your agent's own install command does that, and `install` is only here to",
117
117
  "repair a copy this package made.",
@@ -208,9 +208,15 @@ function maybePrintUpdateNotice(args, ctx) {
208
208
  }
209
209
  export async function main(argv, ctx = defaultCtx(), deps = {}) {
210
210
  // CTC-4625: setup passes the install options through to its engine untouched, so they never meet
211
- // this CLI's own flag tables.
212
- if (argv[0] === "setup")
213
- return runSetup(argv.slice(1), ctx, deps);
211
+ // this CLI's own flag tables. CTC-4680: without --engine, `setup` is the name a person types for
212
+ // the onboard flow, so it runs exactly that.
213
+ if (argv[0] === "setup") {
214
+ const rest = argv.slice(1);
215
+ const own = rest.indexOf("--") === -1 ? rest : rest.slice(0, rest.indexOf("--"));
216
+ if (own.includes("--engine") || own.includes("--help") || own.includes("-h"))
217
+ return runSetup(rest, ctx, deps);
218
+ argv = ["onboard", ...rest];
219
+ }
214
220
  let args;
215
221
  try {
216
222
  args = parseArgs(argv.map((a, i) => (i === 0 && VERB_ALIASES[a] ? VERB_ALIASES[a] : a)));
@@ -231,7 +237,9 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
231
237
  onboardHeadlessRequested(args, ctx.env))
232
238
  return headlessUsageRefusal(ctx, "--help and --version print text, not the headless document; run them without --json", true, manifest.version);
233
239
  if (args.version) {
234
- ctx.stdout(`${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
240
+ ctx.stdout(
241
+ // Machine-read: install.sh and catalyst-cloud's onboarding harness match this exact line.
242
+ `${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
235
243
  return 0;
236
244
  }
237
245
  if (args.command === null) {
@@ -687,26 +695,25 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
687
695
  };
688
696
  checkCancelled();
689
697
  const written = writeConfig(ctx.home, config);
690
- ctx.stdout(`Connected to ${me.name} (${me.slug}) — account ${me.account}`);
698
+ ctx.stdout(`Catalyst workspace: ${me.name} (${me.slug})`);
691
699
  if (me.user) {
692
- ctx.stdout(`Connected as ${me.user.label} (${me.user.role})${me.user.linearUserId
700
+ ctx.stdout(`Signed in to Catalyst as ${me.user.label} (${me.user.role})${me.user.linearUserId
693
701
  ? ""
694
702
  : ' — your Linear identity is not matched yet, so "what needs me" will show everyone\'s asks until an admin matches it in Settings'}`);
695
703
  }
696
704
  else {
697
- ctx.stderr(`[catalyst] this is the tenant's account key (a host credential), not your own — the skills work, but nothing your agent writes will carry your name and "what needs me" cannot mean you. Mint a personal key at Settings → API keys and log in with that.`);
705
+ ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you. Make a personal key at Settings → API keys and sign in with that.`);
698
706
  }
699
707
  ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE
700
708
  ? ""
701
709
  : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
702
- ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange}`);
703
710
  try {
704
711
  const loaded = await loadContract(ctx, config, {
705
712
  refresh: true,
706
713
  signal: deps.signal,
707
714
  });
708
715
  checkCancelled();
709
- ctx.stdout(`Tenant contract ${loaded.doc.contractVersion} cached at ${loaded.path}`);
716
+ ctx.stdout(`Saved your workspace settings (version ${loaded.doc.contractVersion}) to ${loaded.path}`);
710
717
  }
711
718
  catch (err) {
712
719
  checkCancelled();
@@ -54,5 +54,7 @@ export const openConsentBrowser = createConsentBrowserOpener();
54
54
  /** The web page that finishes a consent step when no browser opens here. The signed link stays private. */
55
55
  export function finishOnTheWeb(baseUrl, page, action) {
56
56
  const origin = baseUrl.replace(/\/+$/, "");
57
- return `No browser opened on this computer. In a browser signed in to Catalyst, open ${origin}/settings/${page} and ${action}. Setup keeps waiting here.`;
57
+ // CTC-4680: the GitHub install has a route that goes straight to GitHub; Integrations starts nothing.
58
+ const path = page === "github-install" ? "/connect/github/start" : `/settings/${page}`;
59
+ return `No browser opened on this computer. In a browser signed in to Catalyst, open ${origin}${path} and ${action}. Setup keeps waiting here.`;
58
60
  }
@@ -215,6 +215,18 @@ function observation(body, now) {
215
215
  return waiting("github_installation_status_unavailable");
216
216
  if (outdated)
217
217
  return outdated;
218
+ // CTC-4680 round 5: a request already waiting for an org owner on GitHub is not a new install.
219
+ if (row.installations.length === 0 && row.pending.length > 0) {
220
+ const org = object(row.pending[0])?.githubOrg;
221
+ return {
222
+ state: "waiting",
223
+ reason: "github_installation_approval_pending",
224
+ evidence: {
225
+ provider: "github",
226
+ ...(typeof org === "string" ? { org } : {}),
227
+ },
228
+ };
229
+ }
218
230
  if (!allConnected)
219
231
  return { state: "pending" };
220
232
  if (permissionsUnverified)
@@ -464,7 +476,7 @@ export function githubInstallationAdapter(options) {
464
476
  if (signal?.aborted)
465
477
  return waiting("interrupted");
466
478
  browserUnavailable = true;
467
- ctx.stderr(finishOnTheWeb(current.baseUrl, "connections", "install the GitHub App"));
479
+ ctx.stderr(finishOnTheWeb(current.baseUrl, "github-install", "install Catalyst on your GitHub organization"));
468
480
  }
469
481
  let latest = waiting("github_installation_status_unavailable");
470
482
  const run = () => pollConsent({
@@ -323,7 +323,9 @@ function headlessRetryText(text, keys) {
323
323
  return text
324
324
  .replace(/\b(?:Renew your login with|Sign in as one with|Sign in with|[Rr]un) catalyst login\b/g, key)
325
325
  .replace(/\bRenew your login with catalyst onboard\b/g, key)
326
- .replace(/\bcatalyst onboard\b(?! --help\b)(?: --runner)?(?: again)?/g, "the same headless setup command");
326
+ // CTC-4680: `catalyst setup` is the same flow as `catalyst onboard`, and bare, it drops the
327
+ // headless flags and inputs just the same.
328
+ .replace(/\bcatalyst (?:onboard|setup)\b(?! --help\b)(?: --runner)?(?: again)?/g, "the same headless setup command");
327
329
  }
328
330
  /** The report's view of a finished journal: each unsatisfied step as one named item. */
329
331
  export function headlessStepItems(journal, baseUrl, requireRunner = false, only) {
@@ -457,7 +459,7 @@ export async function runOnboardHeadless(args, ctx, hooks, cliVersion) {
457
459
  const loginGuidance = headlessLoginGuidance(journal.steps.find((step) => step.id === action.step)?.reason ?? "", keys);
458
460
  return { ...action, text: loginGuidance ?? headlessRetryText(action.text, keys), ...(loginGuidance ? { url: keys } : {}) };
459
461
  });
460
- const next = headlessRetryText(view.next.replace(/(?:run )?catalyst onboard(?: --runner)?(?: again)?/g, "run the same headless setup command"), keys);
462
+ const next = headlessRetryText(view.next.replace(/(?:run )?catalyst (?:onboard|setup)(?: --runner)?(?: again)?/g, "run the same headless setup command"), keys);
461
463
  ctx.stdout(JSON.stringify({ ...view, actions, next, headless: report }));
462
464
  }
463
465
  else
@@ -20,8 +20,8 @@ const REASONS = {
20
20
  personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
21
21
  onboard_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
22
22
  personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
23
- workspace_browser_unavailable: "This computer could not open a browser. Connect Linear from this workspace’s Integrations page in your browser, then run catalyst onboard to resume.",
24
- personal_browser_unavailable: "This computer could not open a browser. Finish this login’s connection from Connected accounts in your browser, then run catalyst onboard to resume.",
23
+ workspace_browser_unavailable: "The link to connect Linear timed out. Run catalyst setup for a new one.",
24
+ personal_browser_unavailable: "The link to connect your own account timed out. Run catalyst setup for a new one.",
25
25
  onboarding_capability_unavailable: "The server's setup capabilities could not be checked. Run catalyst onboard to try again.",
26
26
  onboarding_capability_identity_unverified: "The server's setup capabilities belong to another login or workspace. Sign in to the original workspace to resume.",
27
27
  onboarding_capability_login_refresh_required: "Your login needs a refresh before setup capabilities can be checked. Run catalyst login, then catalyst onboard.",
@@ -34,7 +34,10 @@ const REASONS = {
34
34
  github_installation_identity_refused: "GitHub setup belongs to another login or workspace. Sign in to the original workspace to resume.",
35
35
  github_installation_admin_required: "A workspace owner or administrator approves the GitHub App installation.",
36
36
  github_installation_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
37
- github_installation_browser_unavailable: "This computer could not open a browser. Install the GitHub App from this workspace’s Integrations page in your browser, then run catalyst onboard to resume.",
37
+ github_installation_browser_unavailable: "The link to install Catalyst on GitHub timed out. Run catalyst setup for a new one.",
38
+ github_installation_approval_pending: "An install request is waiting on GitHub. An owner of your GitHub organization approves it there, then run catalyst setup.",
39
+ github_install_pending: "Runs after Catalyst is installed on GitHub.",
40
+ consent_timeout: "The link timed out. Run catalyst setup for a new one.",
38
41
  skills_install_unverified: "The Catalyst skills are not in this user's skills folder. Run catalyst install, then run catalyst onboard.",
39
42
  step_not_available_in_this_release: "This setup step is not available yet.",
40
43
  prerequisite_not_ready: "Waiting for an earlier setup step.",
@@ -269,13 +272,22 @@ export function onboardReasonText(step, context = {}) {
269
272
  const path = reason === "workspace_approval_required"
270
273
  ? "/settings/connections?connect=linear"
271
274
  : reason === "github_installation_approval_required"
272
- ? "/settings/connections?install=github"
275
+ ? "/connect/github/start"
273
276
  : `/settings/connected-accounts?connect=${step.id === "github.personal" ? "github" : "linear"}`;
274
277
  const where = context.baseUrl
275
278
  ? `${normalizeBaseUrl(context.baseUrl)}${path}`
276
279
  : "Catalyst settings";
277
280
  return `Open ${where} and approve this connection, then run catalyst onboard.`;
278
281
  }
282
+ // CTC-4680: only a Catalyst owner or admin can start the GitHub install (the start route refuses
283
+ // anyone else), so a member is told who to ask and where, on every channel.
284
+ if (step.id === "github.install" &&
285
+ (reason === "github_installation_admin_required" || reason === "member_scope"))
286
+ return `Only an owner or admin of your Catalyst workspace can install Catalyst on GitHub. Ask one to open ${context.baseUrl ? `${normalizeBaseUrl(context.baseUrl)}/settings/connections` : "Catalyst settings, Integrations,"} and install it there.`;
287
+ // An install request can sit on GitHub for good (a wrong organization, or an owner who said no),
288
+ // so the step always keeps a way to install it directly.
289
+ if (reason === "github_installation_approval_pending" && context.baseUrl)
290
+ return `${REASONS[reason] ?? ""} If you own the organization, or want a different one, install it at ${normalizeBaseUrl(context.baseUrl)}/connect/github/start.`;
279
291
  if (reason === "workflow_mapping_unverified")
280
292
  return `This team's workflow mapping is not verified yet. Run catalyst team adopt ${onboardTeamKey(context.journal) ?? "<TEAM KEY>"} to preview the adoption plan. Run it again with the --yes --plan-hash value it prints. Then run catalyst onboard.`;
281
293
  if (reason === "team_created_not_adopted" ||
@@ -639,9 +639,13 @@ export function onboardRunnerAdapter(input = {}) {
639
639
  if (!admission.ready)
640
640
  return mayAct && !afterAct ? pending() : waiting("runner_admission_disabled", evidence);
641
641
  }
642
- const admission = await onboardTeamAdmission(ctx, p.teamKey, signal);
643
- if (admission !== true)
644
- return waiting(admission === false ? "runner_admission_operator" : "runner_admission_unverified", evidence);
642
+ else {
643
+ // Legacy capacity only names repo-mapped teams. The supported personal route above
644
+ // verifies runner admission independently of repository setup.
645
+ const admission = await onboardTeamAdmission(ctx, p.teamKey, signal);
646
+ if (admission !== true)
647
+ return waiting(admission === false ? "runner_admission_operator" : "runner_admission_unverified", evidence);
648
+ }
645
649
  input.message?.(`This machine runs Catalyst's work as host ${name}, with ${host.capacity} slots free.`);
646
650
  return { state: "done", evidence };
647
651
  },
@@ -18,7 +18,15 @@ import { onboardAccountsAdapter } from "./onboard-accounts.js";
18
18
  import { githubInstallationAdapter } from "./onboard-github.js";
19
19
  import { guardOnboardCapabilities, } from "./onboard-capabilities.js";
20
20
  import { onboardSettingsAdapter, } from "./onboard-settings.js";
21
+ import { TIMED_OUT_REASONS } from "./setup-onboard-copy.js";
21
22
  import { fetchMe } from "./transport.js";
23
+ const HOLD_ON_TIMEOUT = [
24
+ "linear.workspace",
25
+ "linear.personal",
26
+ "github.install",
27
+ "github.personal",
28
+ "accounts",
29
+ ];
22
30
  const waiting = (reason) => ({
23
31
  state: "waiting",
24
32
  reason,
@@ -136,12 +144,19 @@ export function createOnboardRuntime(args, ctx, hooks) {
136
144
  !args.json &&
137
145
  args.flags.yes !== true &&
138
146
  args.flags.headless !== true;
147
+ // CTC-4680 round 5: whether the step now acting ran a browser wait, so a timed-out wait can be
148
+ // re-offered with a fresh link instead of moving on to steps that need it.
149
+ let waited = false;
150
+ const waitFor = (message, run) => {
151
+ waited = true;
152
+ return hooks.ui.wait(message, run);
153
+ };
139
154
  const personalAdapter = (provider) => personalConsentAdapter({
140
155
  provider,
141
156
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
142
157
  sleep: hooks.sleep,
143
158
  wait: interactiveWait
144
- ? (message, run) => hooks.ui.wait(message, run)
159
+ ? (message, run) => waitFor(message, run)
145
160
  : undefined,
146
161
  });
147
162
  const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
@@ -218,14 +233,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
218
233
  fallback: linear["linear.workspace"],
219
234
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
220
235
  wait: interactiveWait
221
- ? (message, work) => hooks.ui.wait(message, work)
236
+ ? (message, work) => waitFor(message, work)
222
237
  : undefined,
223
238
  sleep: hooks.sleep,
224
239
  }),
225
240
  "github.install": githubInstallationAdapter({
226
241
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
227
242
  wait: interactiveWait
228
- ? (message, work) => hooks.ui.wait(message, work)
243
+ ? (message, work) => waitFor(message, work)
229
244
  : undefined,
230
245
  sleep: hooks.sleep,
231
246
  }),
@@ -254,7 +269,7 @@ export function createOnboardRuntime(args, ctx, hooks) {
254
269
  args.flags.yes !== true &&
255
270
  args.flags.headless !== true
256
271
  ? {
257
- waitForAccount: (work) => hooks.ui.wait("Waiting for a coding account", work),
272
+ waitForAccount: (work) => waitFor("Waiting for a coding account", work),
258
273
  sleep: hooks.sleep,
259
274
  }
260
275
  : {}),
@@ -376,6 +391,49 @@ export function createOnboardRuntime(args, ctx, hooks) {
376
391
  message: (text) => hooks.ui ? hooks.ui.message(text) : ctx.stderr(text),
377
392
  });
378
393
  }
394
+ // CTC-4680 round 5: a browser link that ran out of time holds the step. The person gets a fresh
395
+ // link on yes; on stop the UI pauses setup, so nothing that depends on the step runs.
396
+ const retry = interactiveWait ? hooks.ui?.retryTimedOut?.bind(hooks.ui) : undefined;
397
+ if (retry)
398
+ for (const step of HOLD_ON_TIMEOUT) {
399
+ const adapter = adapters[step];
400
+ if (!adapter?.act)
401
+ continue;
402
+ const act = adapter.act;
403
+ adapters[step] = {
404
+ ...adapter,
405
+ act: async (stepCtx, journal, signal) => {
406
+ for (;;) {
407
+ waited = false;
408
+ const result = await act(stepCtx, journal, signal);
409
+ const timedOut = result.state === "waiting" &&
410
+ (TIMED_OUT_REASONS.has(result.reason ?? "") ||
411
+ (step === "accounts" &&
412
+ result.reason === "account_enrollment_required"));
413
+ if (!timedOut || !waited || signal?.aborted || hooks.ui?.signal.aborted)
414
+ return result;
415
+ if (!(await retry(step)))
416
+ return result;
417
+ }
418
+ },
419
+ };
420
+ }
421
+ // Round 5: the runner question waits until Catalyst is on GitHub; asking it earlier left people
422
+ // answering later steps while nothing could work.
423
+ const runner = adapters.runner;
424
+ if (runner?.act && hooks.ui?.chooseRunner && args.flags.runner !== true && args.flags["no-runner"] !== true) {
425
+ const { check, act } = runner;
426
+ const githubPending = (journal) => {
427
+ const github = journal.steps.find((step) => step.id === "github.install");
428
+ return Boolean(github && github.state !== "done" && github.state !== "skipped");
429
+ };
430
+ // Both: the engine runs check before act, and the runner's check is where the question is asked.
431
+ adapters.runner = {
432
+ ...runner,
433
+ check: async (stepCtx, journal, signal) => githubPending(journal) ? waiting("github_install_pending") : check(stepCtx, journal, signal),
434
+ act: async (stepCtx, journal, signal) => githubPending(journal) ? waiting("github_install_pending") : act(stepCtx, journal, signal),
435
+ };
436
+ }
379
437
  // Public auth discovery and the existing /me sign-in bootstrap establish identity before the
380
438
  // tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
381
439
  return {
@@ -508,6 +508,40 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
508
508
  }
509
509
  return answer === "yes";
510
510
  },
511
+ async retryTimedOut(id) {
512
+ if (abort.signal.aborted)
513
+ return false;
514
+ if (renderer && active) {
515
+ // The wait frame is still live: its status and a last line say why it ended.
516
+ renderer.update("the link timed out");
517
+ renderer.detail("The link timed out.");
518
+ }
519
+ else if (renderer) {
520
+ renderer.begin(SETUP_NUMBERS[id] ?? 0, title(id), "the link timed out");
521
+ renderer.detail("The link timed out.");
522
+ active = true;
523
+ }
524
+ else {
525
+ stop();
526
+ prompts.log.message(`${title(id)}: the link timed out.`, {
527
+ output: streams.output,
528
+ });
529
+ }
530
+ const answer = await select({
531
+ ...options,
532
+ message: "Ready to try again?",
533
+ initialValue: "again",
534
+ options: [
535
+ { value: "again", label: "Yes, give me a new link" },
536
+ { value: "stop", label: "Stop here. Run catalyst setup later to carry on." },
537
+ ],
538
+ });
539
+ if (prompts.isCancel(answer) || answer !== "again") {
540
+ abort.abort();
541
+ return false;
542
+ }
543
+ return true;
544
+ },
511
545
  stagedSigninEnd(state, cause) {
512
546
  if (!renderer)
513
547
  return false;
@@ -847,6 +881,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
847
881
  delete ui.chooseFirstRepository;
848
882
  delete ui.reviewSettings;
849
883
  delete ui.chooseRunner;
884
+ delete ui.retryTimedOut;
850
885
  delete ui.confirmWorkflowAdoption;
851
886
  }
852
887
  return ui;
package/dist/onboard.js CHANGED
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join } from "node:path";
6
6
  import { parseMachinePaths, resolveCatalystPath, } from "../vendor/paths/index.js";
7
7
  import { machinePathsFile } from "../vendor/paths/node.js";
8
8
  import { configPathFor } from "./config.js";
9
- import { onboardJsonView } from "./setup-onboard-copy.js";
9
+ import { onboardJsonView, TIMED_OUT_REASONS } from "./setup-onboard-copy.js";
10
10
  import { standalonePlan, standalonePlanNotes, } from "./onboard-standalone-copy.js";
11
11
  import { CliError, UsageError } from "./errors.js";
12
12
  import { onboardFileSnapshot } from "./onboard-file-snapshot.js";
@@ -51,10 +51,11 @@ export function onboardIdentityLines(identity) {
51
51
  const person = label(display?.personLabel || identity.membershipId);
52
52
  const email = display?.email ? label(display.email) : "";
53
53
  const workspace = label(display?.workspaceName || identity.account);
54
- const slug = display?.workspaceSlug ? label(display.workspaceSlug) : "";
54
+ // CTC-4680 (Ryan, round 2): "Signed in to Catalyst as …" and "Catalyst workspace: …", with no
55
+ // repeated email and no internal slug.
55
56
  return [
56
- `Signed in as ${person}${email ? ` (${email})` : ""}`,
57
- `Workspace: ${workspace}${slug ? ` (${slug})` : ""} · ${identity.role}`,
57
+ `Signed in to Catalyst as ${person}${email && email !== person ? ` (${email})` : ""}`,
58
+ `Catalyst workspace: ${workspace} · ${identity.role}`,
58
59
  ];
59
60
  }
60
61
  const EXIT_FAILED = 10;
@@ -1398,7 +1399,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1398
1399
  }
1399
1400
  if (signal.aborted) {
1400
1401
  const settled = journalStep(journal, id);
1401
- if (settled?.state !== "done" && settled?.state !== "failed")
1402
+ // CTC-4680 round 5: a person who stopped at "Ready to try again?" stopped a step whose link
1403
+ // timed out; keep that reason so the summary says so, not "skipped for now".
1404
+ if (settled?.state !== "done" &&
1405
+ settled?.state !== "failed" &&
1406
+ !TIMED_OUT_REASONS.has(settled?.reason ?? ""))
1402
1407
  recordStep({
1403
1408
  id,
1404
1409
  state: "waiting",
@@ -0,0 +1,238 @@
1
+ // ready-copy.ts — CTC-4680: what `catalyst ready` says about a team, in words a person can act on.
2
+ //
3
+ // The check ids, states and reason codes stay in `--json` (`id`) for scripts. The `line`, `fix` and
4
+ // `who` a person reads never carry a check id, a reason code, an API route or the word "tenant", and
5
+ // every fix is either a full URL or a step in Linear's own settings. `line` keeps its `team KEY: `
6
+ // prefix because install.sh strips exactly that before printing a team row.
7
+ const FIXER = {
8
+ oauth_scope: "owner_or_admin",
9
+ token_live: "owner_or_admin",
10
+ team_visible: "owner_or_admin_in_linear",
11
+ mapped_states_exist: "owner_or_admin",
12
+ mapping_total: "owner_or_admin",
13
+ types_compatible: "owner_or_admin",
14
+ labels_present: "nobody",
15
+ writes_land: "owner_or_admin",
16
+ webhook_covers_team: "owner_or_admin",
17
+ hosts_current: "host_operator",
18
+ environment_declared: "owner_or_admin",
19
+ tools_resolvable: "owner_or_admin",
20
+ required_values: "owner_or_admin",
21
+ reviewer_required: "owner_or_admin",
22
+ reviewer_configured: "nobody",
23
+ reviewer_answering: "repository_admin",
24
+ linear_automation_pr_open: "owner_or_admin_in_linear",
25
+ linear_automation_pr_review: "owner_or_admin_in_linear",
26
+ linear_automation_pr_ready: "owner_or_admin_in_linear",
27
+ linear_automation_pr_merge: "owner_or_admin_in_linear",
28
+ merge_queue_configured: "repository_admin",
29
+ coding_account_enrolled: "owner_or_admin",
30
+ github_app_installed: "repository_admin",
31
+ thoughts_reachable: "repository_admin",
32
+ };
33
+ const WHO = {
34
+ nobody: "nobody; it clears on its own",
35
+ owner_or_admin: "an owner or admin of your Catalyst workspace",
36
+ owner_or_admin_in_linear: "an admin of your Linear workspace",
37
+ host_operator: "whoever runs your Catalyst host",
38
+ repository_admin: "an admin of the team's GitHub repository",
39
+ };
40
+ /** Who fixes a check, as a phrase. An unknown check goes to the workspace's owners and admins. */
41
+ export function whoFixes(checkId) {
42
+ return WHO[FIXER[checkId] ?? "owner_or_admin"];
43
+ }
44
+ const SLOT_WORDS = {
45
+ dispatch: "starting work",
46
+ intake: "intake",
47
+ research: "research",
48
+ plan: "planning",
49
+ implement: "writing the code",
50
+ remediate: "fixing review findings",
51
+ verify: "checking the work",
52
+ review: "review",
53
+ pr: "pull requests",
54
+ done: "Done",
55
+ canceled: "Canceled",
56
+ };
57
+ function wordList(items) {
58
+ if (items.length <= 1)
59
+ return items.join("");
60
+ return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
61
+ }
62
+ function slotList(slots) {
63
+ return wordList(slots.map((s) => SLOT_WORDS[s] ?? s.replaceAll("_", " ")));
64
+ }
65
+ function origin(baseUrl) {
66
+ return baseUrl.replace(/\/+$/, "");
67
+ }
68
+ /** The team's own page in Catalyst settings, optionally one of its sub-pages (`map`, `adopt`). */
69
+ export function teamPage(baseUrl, key, sub) {
70
+ return `${origin(baseUrl)}/settings/linear-teams/${encodeURIComponent(key)}${sub ? `/${sub}` : ""}`;
71
+ }
72
+ /** The dispatch gate: whether Catalyst may start any ticket in the team. */
73
+ export function dispatchGateCopy(baseUrl, key, status, missingSlots) {
74
+ if (status === "open")
75
+ return { line: `team ${key}: Catalyst can start ${key} tickets` };
76
+ // CTC-4708: the cloud says the mapping is saved and its copy of Linear hasn't caught up. That is a
77
+ // wait, not something to fix.
78
+ if (status === "stages_syncing")
79
+ return {
80
+ line: `team ${key}: Catalyst is still copying ${key}'s stages from Linear. It starts ${key} tickets once that finishes.`,
81
+ };
82
+ const slots = missingSlots.length > 0 ? slotList(missingSlots) : "its moves";
83
+ if (status === "mapping_missing")
84
+ return {
85
+ line: `team ${key}: Catalyst doesn't know which ${key} stages to use for ${slots}. Until it does, it starts no ${key} tickets.`,
86
+ fix: `pick them at ${teamPage(baseUrl, key, "map")}`,
87
+ };
88
+ if (status === "mapping_state_unresolved")
89
+ // The gate reads Catalyst's own copy of the team, which can lag Linear by a minute after a team
90
+ // is created or its workflow is set up (CTC-4680 Rerun A). Say that, not "map your stages".
91
+ return {
92
+ line: `team ${key}: Catalyst can't find the stages it uses for ${slots} in its copy of ${key} yet. Until it can, it starts no ${key} tickets.`,
93
+ fix: `if you just set up ${key}, run catalyst ready again in a minute. If this stays, press Re-check at ${teamPage(baseUrl, key)}`,
94
+ };
95
+ return {
96
+ line: `team ${key}: Catalyst can't start ${key} tickets yet.`,
97
+ fix: `open ${teamPage(baseUrl, key)} to see why`,
98
+ };
99
+ }
100
+ const AUTOMATION_RULES = {
101
+ linear_automation_pr_open: { rule: "On PR open", when: "when a pull request opens" },
102
+ linear_automation_pr_review: {
103
+ rule: "On PR review request or activity",
104
+ when: "when a pull request gets a review",
105
+ },
106
+ linear_automation_pr_ready: {
107
+ rule: "On PR ready for merge",
108
+ when: "when a pull request is ready to merge",
109
+ },
110
+ linear_automation_pr_merge: { rule: "On PR merge", when: "when a pull request merges" },
111
+ };
112
+ /** Plain names for checks with no sentence of their own below. */
113
+ const CHECK_NAMES = {
114
+ labels_present: "Catalyst's labels in Linear",
115
+ writes_land: "Catalyst's changes to Linear",
116
+ webhook_covers_team: "events from Linear and GitHub for this team",
117
+ hosts_current: "your connected Catalyst hosts",
118
+ environment_declared: "the repository's settings file",
119
+ tools_resolvable: "the tools the repository's settings name",
120
+ reviewer_required: "the required code reviewer",
121
+ reviewer_configured: "a code reviewer",
122
+ reviewer_answering: "the code reviewer's answers",
123
+ merge_queue_configured: "the repository's merge queue",
124
+ github_app_installed: "Catalyst on GitHub",
125
+ thoughts_reachable: "the thoughts repository",
126
+ };
127
+ /** The names above that read as plural ("labels need attention"). */
128
+ const PLURAL = new Set([
129
+ "labels_present",
130
+ "writes_land",
131
+ "webhook_covers_team",
132
+ "hosts_current",
133
+ "tools_resolvable",
134
+ "reviewer_answering",
135
+ ]);
136
+ /** The settings page that owns each check, from the cloud's CHECK_COPY settingsPath. */
137
+ const CHECK_PAGES = {
138
+ oauth_scope: "/settings/connections",
139
+ token_live: "/settings/connections",
140
+ webhook_covers_team: "/settings/projects",
141
+ hosts_current: "/settings/projects",
142
+ environment_declared: "/settings/projects",
143
+ tools_resolvable: "/settings/environment",
144
+ required_values: "/settings/projects",
145
+ reviewer_required: "/settings/projects",
146
+ reviewer_configured: "/settings/projects",
147
+ reviewer_answering: "/settings/projects",
148
+ merge_queue_configured: "/settings/projects",
149
+ coding_account_enrolled: "/settings/coding-accounts",
150
+ github_app_installed: "/settings/projects",
151
+ thoughts_reachable: "/settings/projects",
152
+ };
153
+ const AGAIN = "run catalyst ready again in a few minutes";
154
+ /** One failing or unknown team check, in plain words. `valuesFix` is the variable-naming fix the
155
+ * caller already built for `required_values`, or null when the cloud named no variable. */
156
+ export function teamCheckCopy(baseUrl, key, id, state, valuesFix) {
157
+ const team = (text) => `team ${key}: ${text}`;
158
+ const base = origin(baseUrl);
159
+ const unknown = state === "unknown";
160
+ const automation = AUTOMATION_RULES[id];
161
+ if (automation)
162
+ return unknown
163
+ ? {
164
+ line: team(`Catalyst couldn't read ${key}'s pull request automations in Linear.`),
165
+ fix: AGAIN,
166
+ }
167
+ : {
168
+ line: team(`Linear moves ${key}'s tickets by itself ${automation.when}. That fights Catalyst, which moves them itself.`),
169
+ fix: `in Linear, open Settings → Teams → ${key} → Workflow → Pull request and commit automations and set "${automation.rule}" to No action, including any branch overrides. Then run catalyst ready again`,
170
+ };
171
+ switch (id) {
172
+ case "oauth_scope":
173
+ return unknown
174
+ ? { line: team("Catalyst couldn't check its Linear connection."), fix: `check that Linear is connected at ${base}/settings/connections` }
175
+ : {
176
+ line: team("Catalyst's Linear connection is missing a permission it needs."),
177
+ fix: `reconnect Linear at ${base}/settings/connections?reauthorize=linear`,
178
+ };
179
+ case "token_live":
180
+ return unknown
181
+ ? { line: team("Catalyst couldn't reach Linear to check its connection."), fix: AGAIN }
182
+ : {
183
+ line: team("Linear stopped accepting Catalyst's connection. It was revoked or expired."),
184
+ fix: `reconnect Linear at ${base}/settings/connections`,
185
+ };
186
+ case "team_visible":
187
+ return unknown
188
+ ? { line: team(`Catalyst hasn't confirmed it can see ${key} in Linear yet.`), fix: AGAIN }
189
+ : {
190
+ line: team(`Catalyst can't see ${key} in Linear, usually because the team is private.`),
191
+ fix: `in Linear, open Settings → API → Catalyst Cloud → Team access and add ${key}`,
192
+ };
193
+ case "mapped_states_exist":
194
+ case "mapping_total":
195
+ case "types_compatible":
196
+ if (unknown)
197
+ return { line: team(`Catalyst is still reading ${key}'s stages from Linear.`), fix: "run catalyst ready again in a minute" };
198
+ if (id === "mapped_states_exist")
199
+ return {
200
+ line: team(`A stage Catalyst uses in ${key} was deleted or renamed in Linear.`),
201
+ fix: `pick its replacement at ${teamPage(baseUrl, key, "map")}`,
202
+ };
203
+ if (id === "mapping_total")
204
+ return {
205
+ line: team(`${key}'s stages aren't all mapped yet, so Catalyst doesn't know where to move its tickets.`),
206
+ fix: `map them at ${teamPage(baseUrl, key, "map")}, or set up Catalyst's workflow at ${teamPage(baseUrl, key, "adopt")}`,
207
+ };
208
+ return {
209
+ line: team(`A stage mapped in ${key} is the wrong kind, for example a Done stage that Linear treats as in progress.`),
210
+ fix: `choose the right stages at ${teamPage(baseUrl, key, "map")}`,
211
+ };
212
+ case "required_values":
213
+ return unknown
214
+ ? {
215
+ line: team(`Catalyst hasn't read the settings of ${key}'s repository yet.`),
216
+ fix: "catalyst setup reads them when you choose repositories. Run catalyst setup",
217
+ }
218
+ : {
219
+ line: team(`A variable ${key}'s repository needs has no value, so Catalyst can't run its work.`),
220
+ fix: valuesFix ?? `add the missing values on the repository's Environment page: open ${base}/settings/projects, then the project, then the repository`,
221
+ };
222
+ case "coding_account_enrolled":
223
+ return unknown
224
+ ? { line: team("Catalyst couldn't check your AI accounts."), fix: AGAIN }
225
+ : {
226
+ line: team("Catalyst has no AI account to do the work with."),
227
+ fix: `run catalyst setup to add one, or add it at ${base}/settings/coding-accounts`,
228
+ };
229
+ }
230
+ const name = CHECK_NAMES[id] ?? id.replaceAll("_", " ");
231
+ const page = CHECK_PAGES[id] ?? `/settings/linear-teams/${encodeURIComponent(key)}`;
232
+ return unknown
233
+ ? { line: team(`Catalyst hasn't checked ${name} yet.`), fix: AGAIN }
234
+ : {
235
+ line: team(`${name.charAt(0).toUpperCase()}${name.slice(1)} ${PLURAL.has(id) ? "need" : "needs"} attention.`),
236
+ fix: `open ${base}${page}`,
237
+ };
238
+ }
package/dist/ready.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // is a note, never a failure; the HTTP SDK loading is a check for cloud reads.
4
4
  import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync, } from "node:fs";
5
5
  import { basename, delimiter, dirname, join } from "node:path";
6
- import { LEGACY_PACKAGE_NAME, PACKAGE_NAME, defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, upgradeCommand, } from "./config.js";
6
+ import { DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, upgradeCommand, } from "./config.js";
7
7
  import { contractVersionInRange, readContractCache } from "./contract.js";
8
8
  import { CliError } from "./errors.js";
9
9
  import { latestPublishedVersion } from "./published.js";
@@ -15,6 +15,7 @@ import { FIRST_STAMPED_VERSION } from "./skill-shape.js";
15
15
  import { installedBundleVersion } from "./skills.js";
16
16
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
17
17
  import { selectedOnboardTeam } from "./onboard-existing.js";
18
+ import { dispatchGateCopy, teamCheckCopy, teamPage, whoFixes } from "./ready-copy.js";
18
19
  import { onboardStatePath, readOnboardJournal } from "./onboard.js";
19
20
  export { semverOlder };
20
21
  /** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
@@ -40,7 +41,7 @@ function readyReplicaLine(s) {
40
41
  case "not-configured":
41
42
  return "replica: not configured — run login first";
42
43
  case "absent":
43
- return `replica: absent at ${s.dbPath} — optional, and off by default for large tenants while the snapshot path is being made safe; every read works through the API`;
44
+ return `replica: absent at ${s.dbPath} — optional, and off by default for large workspaces while the snapshot path is being made safe; every read works through the API`;
44
45
  case "fresh":
45
46
  return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
46
47
  case "stale":
@@ -132,7 +133,7 @@ function unresolvedList(v) {
132
133
  }
133
134
  function unresolvedLine(u) {
134
135
  const which = u.references.length === 1 ? "which has" : "which have";
135
- return `${u.name} references ${u.references.join(", ")}, ${which} no value; the checkout refuses it before work starts`;
136
+ return `${u.name} refers to ${u.references.join(", ")}, ${which} no value, so Catalyst can't start work there`;
136
137
  }
137
138
  /** A team check's fix line. A check that carries `names` (CTC-3561: `required_values`, contract
138
139
  * 1.24.0) names them and where to set them. CTC-3606: it also names each `unresolved` variable and
@@ -141,24 +142,13 @@ function unresolvedLine(u) {
141
142
  * "set". Every field is optional so an older cloud still works. Everything printed is a declared
142
143
  * identifier or a repository name, never a value, and nothing here reads a value. Any other check
143
144
  * keeps the generic line. */
144
- function teamCheckFix(label, c) {
145
- const automationRules = {
146
- linear_automation_pr_open: "On PR open",
147
- linear_automation_pr_review: "On PR review request or activity",
148
- linear_automation_pr_ready: "On PR ready for merge",
149
- linear_automation_pr_merge: "On PR merge",
150
- };
151
- const rule = automationRules[c.id];
152
- if (rule) {
153
- return `in Linear, open Settings → Teams → ${label} → Workflow → Workflows & automations → Pull request and commit automations and set ${rule} to No action, including branch-specific overrides; then run catalyst team check ${label}`;
154
- }
145
+ function valuesFix(base, c) {
155
146
  const unresolved = unresolvedList(c.unresolved);
156
147
  const unresolvedNames = new Set(unresolved.map((u) => u.name));
157
148
  const names = nameList(c.names).filter((n) => !unresolvedNames.has(n));
158
149
  const parts = [];
159
150
  if (names.length > 0) {
160
- parts.push(`set ${names.join(", ")} on the repository's Environment page under Settings → Your projects → the project → Repositories → the repository ` +
161
- `(team ${label}; ${names.length === 1 ? "it has" : "they have"} no value at repository or account scope)`);
151
+ parts.push(`set ${names.join(", ")} on the repository's Environment page: open ${base}/settings/projects, then the project, then the repository`);
162
152
  }
163
153
  for (const u of unresolved)
164
154
  parts.push(unresolvedLine(u));
@@ -176,15 +166,7 @@ function teamCheckFix(label, c) {
176
166
  for (const u of repoUnresolved)
177
167
  parts.push(`in ${note.repo}, ${unresolvedLine(u)}`);
178
168
  }
179
- if (parts.length === 0)
180
- return `open settings for team ${label} and resolve ${c.id}`;
181
- return parts.join(". ");
182
- }
183
- function whoCanAnswer(doc) {
184
- const roles = doc.humans.map((h) => `${h.role} ${h.linearUserId}`);
185
- return roles.length
186
- ? roles.join(", ")
187
- : "a tenant owner or admin (none resolved on the contract)";
169
+ return parts.length === 0 ? null : parts.join(". ");
188
170
  }
189
171
  export async function readyReport(ctx, deps) {
190
172
  const checks = [];
@@ -265,7 +247,7 @@ export async function readyReport(ctx, deps) {
265
247
  id: "bundle",
266
248
  ok: false,
267
249
  note: true,
268
- line: `bundle: ${installed} installed is older than the tenant's minimum ${minVersion} — upgrade: ${upgradeCommand()}`,
250
+ line: `bundle: ${installed} installed is older than your workspace's minimum ${minVersion} — upgrade: ${upgradeCommand()}`,
269
251
  });
270
252
  }
271
253
  }
@@ -394,7 +376,7 @@ export async function readyReport(ctx, deps) {
394
376
  });
395
377
  if (cache) {
396
378
  const doc = cache.doc;
397
- const who = whoCanAnswer(doc);
379
+ const base = (cfg?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
398
380
  for (const team of doc.teams) {
399
381
  const label = team.key ?? team.id;
400
382
  // The team's dispatch gate, straight off the cached contract: an older cloud omits it and this
@@ -404,28 +386,36 @@ export async function readyReport(ctx, deps) {
404
386
  // checked still reports its gate, which is exactly the team most likely to be unmapped.
405
387
  const dg = team.dispatchGate;
406
388
  if (dg && typeof dg.status === "string") {
407
- const slots = (dg.missingSlots ?? []).join(", ");
408
- checks.push(dg.status === "open"
389
+ // CTC-4680: the cloud's own remedy text names an API route and says "tenant", so the words
390
+ // come from ready-copy; the status and slots stay readable in the check id's JSON neighbours.
391
+ const copy = dispatchGateCopy(base, label, dg.status, dg.missingSlots ?? []);
392
+ checks.push(dg.status === "stages_syncing"
409
393
  ? {
410
- id: `team:${label}:dispatchGate`,
411
- ok: true,
412
- line: `team ${label}: dispatch gate open`,
413
- }
414
- : {
415
394
  id: `team:${label}:dispatchGate`,
416
395
  ok: false,
417
- line: `team ${label}: dispatch gate ${dg.status}${slots ? ` (${slots})` : ""}, blocking`,
418
- fix: dg.remedy ??
419
- `open settings for team ${label} and map its stages`,
420
- who,
421
- });
396
+ note: true,
397
+ line: copy.line,
398
+ }
399
+ : dg.status === "open"
400
+ ? {
401
+ id: `team:${label}:dispatchGate`,
402
+ ok: true,
403
+ line: copy.line,
404
+ }
405
+ : {
406
+ id: `team:${label}:dispatchGate`,
407
+ ok: false,
408
+ line: copy.line,
409
+ ...(copy.fix ? { fix: copy.fix } : {}),
410
+ who: whoFixes("mapping_total"),
411
+ });
422
412
  }
423
413
  if (team.readiness.status === "unchecked") {
424
414
  checks.push({
425
415
  id: `team:${label}`,
426
416
  ok: true,
427
417
  note: true,
428
- line: `team ${label}: readiness not checked yet`,
418
+ line: `team ${label}: Catalyst hasn't checked this team yet`,
429
419
  });
430
420
  continue;
431
421
  }
@@ -441,13 +431,14 @@ export async function readyReport(ctx, deps) {
441
431
  for (const c of bad) {
442
432
  const meta = doc.readinessChecks.find((r) => r.id === c.id);
443
433
  const needsAnswer = meta?.needsAnswer ?? true;
434
+ const copy = teamCheckCopy(base, label, c.id, c.state === "fail" ? "fail" : "unknown", valuesFix(base, c));
444
435
  checks.push({
445
436
  id: `team:${label}:${c.id}`,
446
437
  ok: !needsAnswer && c.state !== "fail",
447
438
  note: !needsAnswer,
448
- line: `team ${label}: ${c.id} is ${c.state}${c.reason ? ` (${c.reason}${c.count !== undefined ? ` ×${c.count}` : ""})` : ""}${meta ? `, ${meta.severity}` : ""}`,
449
- fix: teamCheckFix(label, c),
450
- who: needsAnswer ? who : "nobody yet; it is informational",
439
+ line: copy.line,
440
+ ...(copy.fix ? { fix: copy.fix } : {}),
441
+ who: needsAnswer ? whoFixes(c.id) : "nobody yet; it is informational",
451
442
  });
452
443
  }
453
444
  if (bad.length === 0 && team.readiness.status !== "ready") {
@@ -455,9 +446,11 @@ export async function readyReport(ctx, deps) {
455
446
  id: `team:${label}`,
456
447
  ok: team.readiness.status !== "blocked",
457
448
  note: team.readiness.status === "degraded",
458
- line: `team ${label}: ${team.readiness.status}`,
459
- fix: `open settings for team ${label}`,
460
- who,
449
+ line: team.readiness.status === "degraded"
450
+ ? `team ${label}: works, with some optional settings still missing`
451
+ : `team ${label}: not ready for work yet`,
452
+ fix: `open ${teamPage(base, label)}`,
453
+ who: whoFixes("mapping_total"),
461
454
  });
462
455
  }
463
456
  }
@@ -47,6 +47,16 @@ export const CONNECTION_REPAIR_REASONS = new Set([
47
47
  "github_app_permissions_unverified", "github_app_repository_access_unverified",
48
48
  "linear_workspace_permissions_unverified", "personal_permissions_unverified",
49
49
  ]);
50
+ /** CTC-4680: the signed-in browser route that goes straight to GitHub's install page. The settings
51
+ * page it replaces opened Integrations and started nothing. */
52
+ export const GITHUB_INSTALL_START = "/connect/github/start";
53
+ /** CTC-4680 round 5: a browser wait that ran its whole window without the person finishing. */
54
+ export const TIMED_OUT_REASONS = new Set([
55
+ "consent_timeout",
56
+ "workspace_browser_unavailable",
57
+ "personal_browser_unavailable",
58
+ "github_installation_browser_unavailable",
59
+ ]);
50
60
  const clean = (s) => s.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 300);
51
61
  function value(step, key) {
52
62
  const v = step.evidence?.[key];
@@ -114,6 +124,10 @@ function unfinished(step) {
114
124
  return "could not read them; checked again before work starts";
115
125
  if (step.reason === "interrupted")
116
126
  return "skipped for now";
127
+ if (TIMED_OUT_REASONS.has(step.reason ?? ""))
128
+ return "the link timed out";
129
+ if (step.reason === "github_installation_approval_pending")
130
+ return "waiting for an owner of your GitHub organization";
117
131
  if (step.state === "failed")
118
132
  return "could not finish this step";
119
133
  switch (step.id) {
@@ -143,6 +157,7 @@ export function setupStepView(step, journal, required = false) {
143
157
  : step.state === "failed"
144
158
  ? "fail"
145
159
  : step.reason === "prerequisite_not_ready" ||
160
+ step.reason === "github_install_pending" ||
146
161
  (step.state === "pending" && !step.reason)
147
162
  ? "later"
148
163
  : !required &&
@@ -160,6 +175,8 @@ export function setupStepView(step, journal, required = false) {
160
175
  .map((id) => SETUP_NUMBERS[id])
161
176
  .filter((n) => n !== undefined)),
162
177
  ];
178
+ if (step.reason === "github_install_pending")
179
+ numbers.splice(0, numbers.length, SETUP_NUMBERS["github.install"]);
163
180
  outcome =
164
181
  step.state === "pending" && !step.reason
165
182
  ? "not checked yet"
@@ -188,7 +205,8 @@ function page(base, path) {
188
205
  }
189
206
  }
190
207
  function action(step, base, journal) {
191
- if (CONNECTION_REPAIR_REASONS.has(step.reason ?? ""))
208
+ if (CONNECTION_REPAIR_REASONS.has(step.reason ?? "") ||
209
+ step.reason === "github_installation_approval_pending")
192
210
  return onboardReasonText(step, { baseUrl: base, journal });
193
211
  const link = (path, verb) => {
194
212
  const url = page(base, path);
@@ -202,7 +220,12 @@ function action(step, base, journal) {
202
220
  case "linear.personal":
203
221
  return link("/settings/connected-accounts?connect=linear", "connect your Linear account");
204
222
  case "github.install":
205
- return link("/settings/connections?install=github", "install Catalyst on your GitHub organization");
223
+ // The start route answers 403 to anyone but an owner or admin, so a member gets the
224
+ // Integrations page and who to ask instead.
225
+ if (step.reason === "github_installation_admin_required" ||
226
+ step.reason === "member_scope")
227
+ return onboardReasonText(step, { baseUrl: base, journal });
228
+ return link(GITHUB_INSTALL_START, "install Catalyst on your GitHub organization");
206
229
  case "github.personal":
207
230
  return link("/settings/connected-accounts?connect=github", "connect your GitHub account");
208
231
  case "accounts":
@@ -317,7 +340,7 @@ export function setupBrowserInstruction(id, base, opened = false) {
317
340
  "Connect your Linear account",
318
341
  ],
319
342
  "github.install": [
320
- "/settings/connections?install=github",
343
+ GITHUB_INSTALL_START,
321
344
  "install Catalyst on your GitHub organization",
322
345
  "Install Catalyst on your GitHub organization",
323
346
  ],
@@ -386,6 +409,8 @@ export function onboardJsonView(journal, base, paused = false, options = {}) {
386
409
  const step = view.steps.find((step) => step.id === action.id);
387
410
  const role = view.steps.find((step) => step.id === "signin" && step.state === "done")?.evidence?.role;
388
411
  const admin = step.reason === "member_scope" ||
412
+ // CTC-4680: refused by the person's Catalyst role, not by GitHub, so a workspace admin acts.
413
+ step.reason === "github_installation_admin_required" ||
389
414
  step.reason?.endsWith("_identity_unverified") ||
390
415
  (action.id === "accounts" && role !== "owner" && role !== "admin");
391
416
  const url = action.text
@@ -86,7 +86,9 @@ export function createSetupRenderer(stream, env) {
86
86
  .replace(/\u001b\]8;;[^\u001b]*\u001b\\/g, "")
87
87
  .replace(/\u001b\[[0-9;]*m/g, "");
88
88
  const rail = traits.unicode ? "│" : "";
89
- const titleCol = traits.unicode ? 6 : 11;
89
+ // CTC-4680: details sit 4 columns in (under the rail in Unicode), not under the step title at 6 or
90
+ // 11, so the eye doesn't travel across a small screen to read them.
91
+ const titleCol = 4;
90
92
  const outcomeCol = traits.unicode ? 38 : 43;
91
93
  const prefix = (col) => rail + " ".repeat(col - rail.length);
92
94
  const mark = (kind) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.15.0",
4
- "description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat \u2014 get set up, see what is happening, see what needs you, run a project, unstick what is stuck \u2014 plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your cloud account with one command. The skills directory is the roster; this line never counts it.",
3
+ "version": "0.15.2",
4
+ "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -140,7 +140,8 @@ const field = (name) => {
140
140
  const line = statusLines.find((l) => l.startsWith(`${name}:`));
141
141
  return line ? line.slice(name.length + 1).trim() : null;
142
142
  };
143
- const tenant = field("Tenant");
143
+ // A newer CLI prints "Catalyst workspace:"; an older one prints "Tenant:".
144
+ const tenant = field("Catalyst workspace") ?? field("Tenant");
144
145
  const api = field("API");
145
146
  const connected = tenant !== null && tryLoadConfig() !== null;
146
147
  // The app and the API share an origin; the base URL is whatever this machine was connected to, so
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # Unstick
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # What needs me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # What this repository needs
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use for "what's happening?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.0 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.2 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10