@catalyst-cloud/cli 0.15.1 → 0.15.3

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +13 -4
  3. package/dist/args.js +1 -1
  4. package/dist/capabilities.js +3 -1
  5. package/dist/cli.js +25 -30
  6. package/dist/consent-browser.js +3 -1
  7. package/dist/oauth.js +1 -1
  8. package/dist/onboard-accounts.js +67 -171
  9. package/dist/onboard-checklist.js +309 -0
  10. package/dist/onboard-existing.js +1 -1
  11. package/dist/onboard-first-ticket.js +199 -0
  12. package/dist/onboard-github.js +13 -1
  13. package/dist/onboard-headless.js +6 -4
  14. package/dist/onboard-housekeeping.js +93 -0
  15. package/dist/onboard-next.js +124 -13
  16. package/dist/onboard-ready.js +61 -0
  17. package/dist/onboard-runtime.js +117 -26
  18. package/dist/onboard-standalone-copy.js +73 -66
  19. package/dist/onboard-ui.js +189 -81
  20. package/dist/onboard-values.js +47 -0
  21. package/dist/onboard.js +59 -18
  22. package/dist/ready-copy.js +325 -0
  23. package/dist/ready.js +35 -98
  24. package/dist/setup-onboard-copy.js +262 -41
  25. package/dist/setup-render.js +73 -14
  26. package/dist/setup.js +6 -3
  27. package/package.json +2 -2
  28. package/skills/catalyst-github/SKILL.md +1 -1
  29. package/skills/catalyst-linear/SKILL.md +1 -1
  30. package/skills/catalyst-onboard/SKILL.md +3 -3
  31. package/skills/catalyst-onboard/references/replacing-a-credential.md +9 -15
  32. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -11
  33. package/skills/catalyst-onboard/scripts/where-am-i.mjs +3 -2
  34. package/skills/run-this-project/SKILL.md +2 -2
  35. package/skills/unstick/SKILL.md +1 -1
  36. package/skills/what-needs-me/SKILL.md +1 -1
  37. package/skills/what-this-repo-needs/SKILL.md +1 -1
  38. package/skills/whats-happening/SKILL.md +2 -2
  39. package/skills/whats-happening/references/coding-accounts.md +1 -1
  40. package/skills/whats-happening/scripts/snapshot.mjs +1 -1
package/dist/onboard.js CHANGED
@@ -6,11 +6,11 @@ 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";
13
- import { onboardReasonText, onboardTeamKey, savedOnboardBaseUrl, } from "./onboard-next.js";
13
+ import { dispatchStageName, onboardReasonText, onboardTeamKey, savedOnboardBaseUrl, } from "./onboard-next.js";
14
14
  import { parseBootstrapPlan, bootstrapPlanHash, bootstrapPlanLines, } from "./onboard-bootstrap.js";
15
15
  export const ONBOARD_STEPS = [
16
16
  "machine",
@@ -37,6 +37,14 @@ export const ONBOARD_STEPS = [
37
37
  "first-ticket",
38
38
  "ready",
39
39
  ];
40
+ function dailyUpdateChoice(value) {
41
+ const row = object(value);
42
+ if (row?.state === "on" || row?.state === "off")
43
+ return { state: row.state };
44
+ if (row?.state === "skipped" && row.reason === "no_scheduler")
45
+ return { state: "skipped", reason: "no_scheduler" };
46
+ return undefined;
47
+ }
40
48
  export function onboardIdentityLines(identity) {
41
49
  if (!identity)
42
50
  return [
@@ -51,10 +59,11 @@ export function onboardIdentityLines(identity) {
51
59
  const person = label(display?.personLabel || identity.membershipId);
52
60
  const email = display?.email ? label(display.email) : "";
53
61
  const workspace = label(display?.workspaceName || identity.account);
54
- const slug = display?.workspaceSlug ? label(display.workspaceSlug) : "";
62
+ // CTC-4680 (Ryan, round 2): "Signed in to Catalyst as …" and "Catalyst workspace: …", with no
63
+ // repeated email and no internal slug.
55
64
  return [
56
- `Signed in as ${person}${email ? ` (${email})` : ""}`,
57
- `Workspace: ${workspace}${slug ? ` (${slug})` : ""} · ${identity.role}`,
65
+ `Signed in to Catalyst as ${person}${email && email !== person ? ` (${email})` : ""}`,
66
+ `Catalyst workspace: ${workspace} · ${identity.role}`,
58
67
  ];
59
68
  }
60
69
  const EXIT_FAILED = 10;
@@ -103,7 +112,8 @@ export const ONBOARD_DEPENDENCIES = {
103
112
  projects: ["linear.team", "github.repos"],
104
113
  accounts: ["signin"],
105
114
  settings: ["projects"],
106
- values: ["settings"],
115
+ // The cloud's required-values check reads the registered repositories, not a local checkout.
116
+ values: ["projects"],
107
117
  capacity: ["projects"],
108
118
  // The join token names the selected team's pool.
109
119
  runner: ["linear.team"],
@@ -126,7 +136,7 @@ const ADMIN_STEPS = new Set([
126
136
  "github.repos",
127
137
  "projects",
128
138
  "settings",
129
- "values",
139
+ // `values` is not here: its check only reads, so a member's run finds missing values too.
130
140
  "capacity",
131
141
  "runner",
132
142
  "first-ticket",
@@ -146,9 +156,9 @@ export const ONBOARD_TITLES = {
146
156
  "github.personal": "Connect your GitHub account",
147
157
  "github.repos": "Choose repositories",
148
158
  projects: "Register your projects",
149
- accounts: "Check coding accounts",
159
+ accounts: "Check AI accounts",
150
160
  settings: "Review repository settings",
151
- values: "Import selected local values",
161
+ values: "Check the repository's values",
152
162
  capacity: "Check runner capacity",
153
163
  runner: "Run Catalyst's work on this machine",
154
164
  daemon: "Check optional local sync",
@@ -250,6 +260,10 @@ function normalizeStep(value, fallbackAt) {
250
260
  "repository",
251
261
  "installation",
252
262
  "ticket",
263
+ // The team's dispatch stage name, as the contract gave it.
264
+ "stage",
265
+ // Admin-owned step ids a member's run found unfinished.
266
+ "admin",
253
267
  "cursor",
254
268
  "lag",
255
269
  "heartbeatAgeMs",
@@ -287,6 +301,8 @@ function normalizeStep(value, fallbackAt) {
287
301
  "org",
288
302
  "url",
289
303
  "actor",
304
+ // CTC-4716: which AI accounts the workspace may add, so the step's text offers only those.
305
+ "aiAccountKinds",
290
306
  ]) {
291
307
  const item = evidence?.[key];
292
308
  if (item === null ||
@@ -322,6 +338,7 @@ export function readOnboardJournal(path, cliVersion = "0.13.1", now = new Date()
322
338
  : typeof value.exitCode === "number"
323
339
  ? value.exitCode
324
340
  : null;
341
+ const dailyUpdate = dailyUpdateChoice(value.dailyUpdate);
325
342
  return {
326
343
  schema: 1,
327
344
  runId: typeof value.runId === "string" ? value.runId : randomUUID(),
@@ -353,6 +370,7 @@ export function readOnboardJournal(path, cliVersion = "0.13.1", now = new Date()
353
370
  ...(typeof value.localSync === "boolean"
354
371
  ? { localSync: value.localSync }
355
372
  : {}),
373
+ ...(dailyUpdate ? { dailyUpdate } : {}),
356
374
  operations: Object.fromEntries(Object.entries(object(value.operations) ?? {}).filter(([key, v]) => ONBOARD_STEPS.includes(key) &&
357
375
  typeof v === "string" &&
358
376
  /^[a-zA-Z0-9_.:-]{1,160}$/.test(v))),
@@ -572,7 +590,8 @@ function printPlan(ctx, journal, identity, options) {
572
590
  for (const line of onboardIdentityLines(identity))
573
591
  ctx.stdout(line);
574
592
  let group;
575
- for (const row of standalonePlan(journal, options?.scope, options?.runner)) {
593
+ // Beside --json and headless output: the numbers JSON actions carry (CTC-4680).
594
+ for (const row of standalonePlan(journal, options?.scope, options?.runner, "json")) {
576
595
  if (row.group !== group) {
577
596
  ctx.stdout("");
578
597
  ctx.stdout(row.group);
@@ -628,14 +647,28 @@ export function stepSatisfied(step) {
628
647
  return (step?.state === "done" ||
629
648
  (step?.state === "skipped" &&
630
649
  (step.id === "legacy" ||
650
+ // Optional: a computer without the daily update still runs work.
651
+ step.id === "housekeeping" ||
631
652
  step.reason === "member_scope" ||
632
653
  (step.id === "daemon" && step.reason === "local_sync_not_selected") ||
633
654
  (step.id === "runner" && step.reason === "runner_not_selected") ||
655
+ (step.id === "first-ticket" && step.reason === "first_ticket_skipped") ||
634
656
  (step.id === "linear.automations" &&
635
657
  step.reason === "automation_management_unavailable"))));
636
658
  }
659
+ /** Optional work this computer or checkout cannot do. Work runs without it, so its wait never holds
660
+ * a complete setup. */
661
+ const OPTIONAL_WAITS = new Set([
662
+ "settings_checkout_unverified",
663
+ "settings_approval_unverified",
664
+ ]);
665
+ /** Satisfied, or an optional wait. What a complete setup needs of every step. */
666
+ export function stepSettled(step) {
667
+ return (stepSatisfied(step) ||
668
+ (step?.state === "waiting" && OPTIONAL_WAITS.has(step.reason ?? "")));
669
+ }
637
670
  /** Steps whose wait does not hold a full run's exit code: work runs without them. A failure still
638
- * fails the run, and the receipt's `complete` still needs them satisfied. Never used for gating. */
671
+ * fails the run, and the receipt's `complete` still needs them settled. Never used for gating. */
639
672
  export const ONBOARD_DEFERRED_STEPS = new Set([
640
673
  "settings",
641
674
  "values",
@@ -676,8 +709,11 @@ export function onboardNextActions(journal, baseUrl, only) {
676
709
  .filter((step) => (!only || step.id === only) && !stepSatisfied(step))
677
710
  .sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id))
678
711
  .map((step) => `${ONBOARD_TITLES[step.id]}: ${onboardStepAction(journal, step, baseUrl)}`);
679
- if (["projects", "accounts", "linear.adopt", "capacity"].every((id) => journalStep(journal, id)?.state === "done"))
680
- lines.push(`Move a ticket in ${onboardTeamKey(journal) ?? "<TEAM KEY>"} to Todo; \`catalyst explain <ticket>\` says why it is or is not starting.`);
712
+ // A waiting first ticket already says to move a ticket, in its own line.
713
+ const firstTicket = journalStep(journal, "first-ticket");
714
+ if (["projects", "accounts", "linear.adopt", "capacity"].every((id) => journalStep(journal, id)?.state === "done") &&
715
+ firstTicket?.state !== "waiting")
716
+ lines.push(`Move a ticket in ${onboardTeamKey(journal) ?? "<TEAM KEY>"} to ${dispatchStageName(firstTicket?.evidence?.stage)}; \`catalyst explain <ticket>\` says why it is or is not starting.`);
681
717
  return lines;
682
718
  }
683
719
  function requireMatchingIdentity(journal, identity) {
@@ -726,9 +762,10 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
726
762
  const only = typeof args.flags.only === "string"
727
763
  ? args.flags.only
728
764
  : undefined;
765
+ // A member's values check reads the selected team, so without --team it waits on the same skip.
729
766
  const memberScopeSkips = (id) => ADMIN_STEPS.has(id) ||
730
- (id === "linear.team" &&
731
- only !== "linear.team" &&
767
+ ((id === "linear.team" || id === "values") &&
768
+ only !== id &&
732
769
  typeof args.flags.team !== "string");
733
770
  const resume = typeof args.flags["resume-from"] === "string"
734
771
  ? args.flags["resume-from"]
@@ -1068,7 +1105,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1068
1105
  journal.complete =
1069
1106
  !only &&
1070
1107
  code === 0 &&
1071
- ONBOARD_STEPS.every((id) => stepSatisfied(journalStep(journal, id)));
1108
+ ONBOARD_STEPS.every((id) => stepSettled(journalStep(journal, id)));
1072
1109
  writeOnboardJournal(statePath, journal);
1073
1110
  if (args.json)
1074
1111
  ctx.stdout(JSON.stringify(journal));
@@ -1398,7 +1435,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1398
1435
  }
1399
1436
  if (signal.aborted) {
1400
1437
  const settled = journalStep(journal, id);
1401
- if (settled?.state !== "done" && settled?.state !== "failed")
1438
+ // CTC-4680 round 5: a person who stopped at "Ready to try again?" stopped a step whose link
1439
+ // timed out; keep that reason so the summary says so, not "skipped for now".
1440
+ if (settled?.state !== "done" &&
1441
+ settled?.state !== "failed" &&
1442
+ !TIMED_OUT_REASONS.has(settled?.reason ?? ""))
1402
1443
  recordStep({
1403
1444
  id,
1404
1445
  state: "waiting",
@@ -1437,7 +1478,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1437
1478
  journal.complete =
1438
1479
  !only &&
1439
1480
  code === 0 &&
1440
- ONBOARD_STEPS.every((id) => stepSatisfied(journalStep(journal, id)));
1481
+ ONBOARD_STEPS.every((id) => stepSettled(journalStep(journal, id)));
1441
1482
  const unfinished = journal.steps.filter((step) => !stepSatisfied(step));
1442
1483
  if (!only &&
1443
1484
  !refused &&
@@ -0,0 +1,325 @@
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
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
69
+ const REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
70
+ const identifiers = (value) => Array.isArray(value)
71
+ ? value.filter((name) => typeof name === "string" && NAME.test(name))
72
+ : [];
73
+ const unresolvedFacts = (value) => Array.isArray(value)
74
+ ? value.flatMap((row) => typeof row?.name === "string" && NAME.test(row.name) && identifiers(row.references).length
75
+ ? [{ name: row.name, references: identifiers(row.references) }]
76
+ : [])
77
+ : [];
78
+ /** The declared names a failing check carries, kept in the receipt for the fix line: which variables
79
+ * need a value, which refer to a secret with no value, and the same for the team's other
80
+ * repositories. Identifiers and repository names only. */
81
+ export function valuesFacts(value) {
82
+ const row = (value ?? {});
83
+ return {
84
+ names: identifiers(row.names),
85
+ unresolved: unresolvedFacts(row.unresolved),
86
+ repos: Array.isArray(row.repos)
87
+ ? row.repos.flatMap((note) => typeof note?.repo === "string" && REPOSITORY.test(note.repo)
88
+ ? [{ repo: note.repo, names: identifiers(note.names), unresolved: unresolvedFacts(note.unresolved) }]
89
+ : [])
90
+ : [],
91
+ };
92
+ }
93
+ /** Where the named variables get their values. Names only: nothing here reads or prints a value. */
94
+ function setValuesFix(baseUrl, names) {
95
+ return baseUrl
96
+ ? `set ${names.join(", ")} on the repository's Environment page: open ${origin(baseUrl)}/settings/projects, then the project, then the repository`
97
+ : `set ${names.join(", ")} on the repository's Environment page in Catalyst's project settings`;
98
+ }
99
+ function nameList(v) {
100
+ return Array.isArray(v)
101
+ ? v.filter((n) => typeof n === "string" && n.length > 0)
102
+ : [];
103
+ }
104
+ /** Entries whose name and references are both present; anything malformed is dropped, not guessed. */
105
+ function unresolvedList(v) {
106
+ if (!Array.isArray(v))
107
+ return [];
108
+ return v.flatMap((u) => {
109
+ if (typeof u !== "object" || u === null)
110
+ return [];
111
+ const { name, references } = u;
112
+ const refs = nameList(references);
113
+ return typeof name === "string" && name.length > 0 && refs.length > 0
114
+ ? [{ name, references: refs }]
115
+ : [];
116
+ });
117
+ }
118
+ function unresolvedLine(u) {
119
+ const which = u.references.length === 1 ? "which has" : "which have";
120
+ return `${u.name} refers to ${u.references.join(", ")}, ${which} no value, so Catalyst can't start work there`;
121
+ }
122
+ /** A team check's fix line. A check that carries `names` (CTC-3561: `required_values`, contract
123
+ * 1.24.0) names them and where to set them. CTC-3606: it also names each `unresolved` variable and
124
+ * the reference with no value, and each other repository's missing names from `repos[]`. `unresolved`
125
+ * is a subset of `names` (the variable exists; its reference does not), so those are not told to be
126
+ * "set". Every field is optional so an older cloud still works. Everything printed is a declared
127
+ * identifier or a repository name, never a value, and nothing here reads a value. Any other check
128
+ * keeps the generic line. Shared with setup's values step, which has no web address when the
129
+ * saved login has none. */
130
+ export function valuesFix(base, c) {
131
+ const unresolved = unresolvedList(c.unresolved);
132
+ const unresolvedNames = new Set(unresolved.map((u) => u.name));
133
+ const names = nameList(c.names).filter((n) => !unresolvedNames.has(n));
134
+ const parts = [];
135
+ if (names.length > 0)
136
+ parts.push(setValuesFix(base, names));
137
+ for (const u of unresolved)
138
+ parts.push(unresolvedLine(u));
139
+ for (const note of Array.isArray(c.repos) ? c.repos : []) {
140
+ if (typeof note !== "object" ||
141
+ note === null ||
142
+ typeof note.repo !== "string" ||
143
+ note.repo.length === 0)
144
+ continue;
145
+ const repoUnresolved = unresolvedList(note.unresolved);
146
+ const skip = new Set(repoUnresolved.map((u) => u.name));
147
+ const missing = nameList(note.names).filter((n) => !skip.has(n));
148
+ if (missing.length > 0)
149
+ parts.push(`${note.repo} is missing ${missing.join(", ")}`);
150
+ for (const u of repoUnresolved)
151
+ parts.push(`in ${note.repo}, ${unresolvedLine(u)}`);
152
+ }
153
+ return parts.length === 0 ? null : parts.join(". ");
154
+ }
155
+ /** The team's own page in Catalyst settings, optionally one of its sub-pages (`map`, `adopt`). */
156
+ export function teamPage(baseUrl, key, sub) {
157
+ return `${origin(baseUrl)}/settings/linear-teams/${encodeURIComponent(key)}${sub ? `/${sub}` : ""}`;
158
+ }
159
+ /** The dispatch gate: whether Catalyst may start any ticket in the team. */
160
+ export function dispatchGateCopy(baseUrl, key, status, missingSlots) {
161
+ if (status === "open")
162
+ return { line: `team ${key}: Catalyst can start ${key} tickets` };
163
+ // CTC-4708: the cloud says the mapping is saved and its copy of Linear hasn't caught up. That is a
164
+ // wait, not something to fix.
165
+ if (status === "stages_syncing")
166
+ return {
167
+ line: `team ${key}: Catalyst is still copying ${key}'s stages from Linear. It starts ${key} tickets once that finishes.`,
168
+ };
169
+ const slots = missingSlots.length > 0 ? slotList(missingSlots) : "its moves";
170
+ if (status === "mapping_missing")
171
+ return {
172
+ line: `team ${key}: Catalyst doesn't know which ${key} stages to use for ${slots}. Until it does, it starts no ${key} tickets.`,
173
+ fix: `pick them at ${teamPage(baseUrl, key, "map")}`,
174
+ };
175
+ if (status === "mapping_state_unresolved")
176
+ // The gate reads Catalyst's own copy of the team, which can lag Linear by a minute after a team
177
+ // is created or its workflow is set up (CTC-4680 Rerun A). Say that, not "map your stages".
178
+ return {
179
+ 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.`,
180
+ fix: `if you just set up ${key}, run catalyst ready again in a minute. If this stays, press Re-check at ${teamPage(baseUrl, key)}`,
181
+ };
182
+ return {
183
+ line: `team ${key}: Catalyst can't start ${key} tickets yet.`,
184
+ fix: `open ${teamPage(baseUrl, key)} to see why`,
185
+ };
186
+ }
187
+ const AUTOMATION_RULES = {
188
+ linear_automation_pr_open: { rule: "On PR open", when: "when a pull request opens" },
189
+ linear_automation_pr_review: {
190
+ rule: "On PR review request or activity",
191
+ when: "when a pull request gets a review",
192
+ },
193
+ linear_automation_pr_ready: {
194
+ rule: "On PR ready for merge",
195
+ when: "when a pull request is ready to merge",
196
+ },
197
+ linear_automation_pr_merge: { rule: "On PR merge", when: "when a pull request merges" },
198
+ };
199
+ /** Plain names for checks with no sentence of their own below. */
200
+ const CHECK_NAMES = {
201
+ labels_present: "Catalyst's labels in Linear",
202
+ writes_land: "Catalyst's changes to Linear",
203
+ webhook_covers_team: "events from Linear and GitHub for this team",
204
+ hosts_current: "your connected Catalyst hosts",
205
+ environment_declared: "the repository's settings file",
206
+ tools_resolvable: "the tools the repository's settings name",
207
+ reviewer_required: "the required code reviewer",
208
+ reviewer_configured: "a code reviewer",
209
+ reviewer_answering: "the code reviewer's answers",
210
+ merge_queue_configured: "the repository's merge queue",
211
+ github_app_installed: "Catalyst on GitHub",
212
+ thoughts_reachable: "the thoughts repository",
213
+ };
214
+ /** The names above that read as plural ("labels need attention"). */
215
+ const PLURAL = new Set([
216
+ "labels_present",
217
+ "writes_land",
218
+ "webhook_covers_team",
219
+ "hosts_current",
220
+ "tools_resolvable",
221
+ "reviewer_answering",
222
+ ]);
223
+ /** The settings page that owns each check, from the cloud's CHECK_COPY settingsPath. */
224
+ const CHECK_PAGES = {
225
+ oauth_scope: "/settings/connections",
226
+ token_live: "/settings/connections",
227
+ webhook_covers_team: "/settings/projects",
228
+ hosts_current: "/settings/projects",
229
+ environment_declared: "/settings/projects",
230
+ tools_resolvable: "/settings/environment",
231
+ required_values: "/settings/projects",
232
+ reviewer_required: "/settings/projects",
233
+ reviewer_configured: "/settings/projects",
234
+ reviewer_answering: "/settings/projects",
235
+ merge_queue_configured: "/settings/projects",
236
+ coding_account_enrolled: "/settings/coding-accounts",
237
+ github_app_installed: "/settings/projects",
238
+ thoughts_reachable: "/settings/projects",
239
+ };
240
+ const AGAIN = "run catalyst ready again in a few minutes";
241
+ /** One failing or unknown team check, in plain words. `valuesFix` is the variable-naming fix the
242
+ * caller already built for `required_values`, or null when the cloud named no variable. */
243
+ export function teamCheckCopy(baseUrl, key, id, state, valuesFix) {
244
+ const team = (text) => `team ${key}: ${text}`;
245
+ const base = origin(baseUrl);
246
+ const unknown = state === "unknown";
247
+ const automation = AUTOMATION_RULES[id];
248
+ if (automation)
249
+ return unknown
250
+ ? {
251
+ line: team(`Catalyst couldn't read ${key}'s pull request automations in Linear.`),
252
+ fix: AGAIN,
253
+ }
254
+ : {
255
+ line: team(`Linear moves ${key}'s tickets by itself ${automation.when}. That fights Catalyst, which moves them itself.`),
256
+ 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`,
257
+ };
258
+ switch (id) {
259
+ case "oauth_scope":
260
+ return unknown
261
+ ? { line: team("Catalyst couldn't check its Linear connection."), fix: `check that Linear is connected at ${base}/settings/connections` }
262
+ : {
263
+ line: team("Catalyst's Linear connection is missing a permission it needs."),
264
+ fix: `reconnect Linear at ${base}/settings/connections?reauthorize=linear`,
265
+ };
266
+ case "token_live":
267
+ return unknown
268
+ ? { line: team("Catalyst couldn't reach Linear to check its connection."), fix: AGAIN }
269
+ : {
270
+ line: team("Linear stopped accepting Catalyst's connection. It was revoked or expired."),
271
+ fix: `reconnect Linear at ${base}/settings/connections`,
272
+ };
273
+ case "team_visible":
274
+ return unknown
275
+ ? { line: team(`Catalyst hasn't confirmed it can see ${key} in Linear yet.`), fix: AGAIN }
276
+ : {
277
+ line: team(`Catalyst can't see ${key} in Linear, usually because the team is private.`),
278
+ fix: `in Linear, open Settings → API → Catalyst Cloud → Team access and add ${key}`,
279
+ };
280
+ case "mapped_states_exist":
281
+ case "mapping_total":
282
+ case "types_compatible":
283
+ if (unknown)
284
+ return { line: team(`Catalyst is still reading ${key}'s stages from Linear.`), fix: "run catalyst ready again in a minute" };
285
+ if (id === "mapped_states_exist")
286
+ return {
287
+ line: team(`A stage Catalyst uses in ${key} was deleted or renamed in Linear.`),
288
+ fix: `pick its replacement at ${teamPage(baseUrl, key, "map")}`,
289
+ };
290
+ if (id === "mapping_total")
291
+ return {
292
+ line: team(`${key}'s stages aren't all mapped yet, so Catalyst doesn't know where to move its tickets.`),
293
+ fix: `map them at ${teamPage(baseUrl, key, "map")}, or set up Catalyst's workflow at ${teamPage(baseUrl, key, "adopt")}`,
294
+ };
295
+ return {
296
+ line: team(`A stage mapped in ${key} is the wrong kind, for example a Done stage that Linear treats as in progress.`),
297
+ fix: `choose the right stages at ${teamPage(baseUrl, key, "map")}`,
298
+ };
299
+ case "required_values":
300
+ return unknown
301
+ ? {
302
+ line: team(`Catalyst hasn't read the settings of ${key}'s repository yet.`),
303
+ fix: "catalyst setup reads them when you choose repositories. Run catalyst setup",
304
+ }
305
+ : {
306
+ line: team(`A variable ${key}'s repository needs has no value, so Catalyst can't run its work.`),
307
+ fix: valuesFix ?? `add the missing values on the repository's Environment page: open ${base}/settings/projects, then the project, then the repository`,
308
+ };
309
+ case "coding_account_enrolled":
310
+ return unknown
311
+ ? { line: team("Catalyst couldn't check your AI accounts."), fix: AGAIN }
312
+ : {
313
+ line: team("Catalyst has no AI account to do the work with."),
314
+ fix: `run catalyst setup to add one, or add it at ${base}/settings/coding-accounts`,
315
+ };
316
+ }
317
+ const name = CHECK_NAMES[id] ?? id.replaceAll("_", " ");
318
+ const page = CHECK_PAGES[id] ?? `/settings/linear-teams/${encodeURIComponent(key)}`;
319
+ return unknown
320
+ ? { line: team(`Catalyst hasn't checked ${name} yet.`), fix: AGAIN }
321
+ : {
322
+ line: team(`${name.charAt(0).toUpperCase()}${name.slice(1)} ${PLURAL.has(id) ? "need" : "needs"} attention.`),
323
+ fix: `open ${base}${page}`,
324
+ };
325
+ }