@catalyst-cloud/cli 0.15.4 → 0.15.6

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.6
4
+
5
+ Setup checks current readiness and waits up to one minute while Catalyst reads repository settings.
6
+
7
+ ## 0.15.5
8
+
9
+ Setup explains what running work on this computer means and lists every repository registered to your Linear team.
10
+
3
11
  ## 0.15.4
4
12
 
5
13
  - Setup shows a colour mark and clearer progress, with a plain display when your terminal needs it.
@@ -242,6 +242,9 @@ export function planOnboardHeadless(args, ctx) {
242
242
  delete flags["key-file"];
243
243
  delete flags.runner;
244
244
  delete flags["no-runner"];
245
+ // CATALYST_ONBOARD_HEADLESS=1 alone leaves the flag unset; the plan names the headless input
246
+ // rather than a --no-runner nobody typed (CTC-4739).
247
+ flags.headless = true;
245
248
  if (runner === "yes")
246
249
  flags.runner = true;
247
250
  if (runner === "no")
@@ -147,7 +147,7 @@ const REASONS = {
147
147
  linear_workspace_permissions_unverified: "The workspace's Linear connection works, but its granted scopes could not be checked. Run catalyst onboard to try again.",
148
148
  linear_personal_scope_outdated: "Your personal Linear connection is missing scopes this version needs. Re-authorize it from Connected accounts, then run catalyst onboard.",
149
149
  personal_permissions_unverified: "Your personal connection's granted scopes could not be checked. Run catalyst onboard to try again.",
150
- required_values_unverified: "Catalyst has not read the repository's settings yet. Run catalyst setup again in a few minutes.",
150
+ required_values_unverified: "Setup couldn't get a current readiness check for this team from Catalyst, so it can't tell whether the repositories' values are set. Run catalyst ready to check.",
151
151
  first_ticket_team_unverified: "Setup could not confirm the selected Linear team. Run catalyst setup again.",
152
152
  first_ticket_dispatch_unmapped: "Catalyst does not know which of the team's stages starts its work yet. Set up the team's Catalyst workflow, then run catalyst setup again.",
153
153
  housekeeping_service_unverified: "The daily update needs a user service manager, launchd on macOS or systemd --user on Linux. Setup cannot schedule it on this computer. It is optional, and work does not depend on it.",
@@ -435,6 +435,12 @@ export function onboardReasonText(step, context = {}) {
435
435
  }
436
436
  if (reason === "runner_admission_operator")
437
437
  return `The admission policy for team ${onboardTeamKey(context.journal) ?? "<TEAM KEY>"} needs a Catalyst operator to check it. Setup changes only restricted admission and preserves existing policy fields. Ask Catalyst support to confirm a compatible policy, then run the same setup command.`;
438
+ // CTC-4744: the cloud has not read the settings yet. Setup already waited a minute; this is
439
+ // the cloud's work, not the person's.
440
+ if (reason === "required_values_unread") {
441
+ const which = typeof step.evidence?.repositories === "string" ? step.evidence.repositories : "this team's repositories";
442
+ return `Catalyst hasn't finished reading the settings of ${which}, so it can't check the values yet. You don't need to do anything: Catalyst does this on its own, and catalyst ready shows when it's done.`;
443
+ }
438
444
  if (reason === "required_values_missing") {
439
445
  let facts;
440
446
  try {
@@ -0,0 +1,32 @@
1
+ // CTC-4744: the team's readiness verdict in the contract lasts five minutes, and the contract never
2
+ // recomputes it. Only a readiness read does. Setup asks for that read before it judges a team, so
3
+ // the end of setup and `catalyst ready` read the same current verdict.
4
+ import { normalizeBaseUrl } from "./config.js";
5
+ import { loadContract } from "./contract.js";
6
+ const READ_THROUGH_TIMEOUT_MS = 30_000;
7
+ /** Ask the cloud to bring each team's verdict up to date, then read the contract past its cache.
8
+ * The read-through's own answer is not trusted: the contract it refreshes is the evidence. A cloud
9
+ * without the route, or any failure here, leaves the verdict as it was. */
10
+ export async function loadFreshTeamContract(ctx, cfg, teamIds, signal, timeoutMs = READ_THROUGH_TIMEOUT_MS) {
11
+ const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
12
+ const bearer = cfg.key ||
13
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
14
+ if (bearer)
15
+ for (const id of new Set(teamIds)) {
16
+ if (signal?.aborted)
17
+ break;
18
+ const deadline = AbortSignal.timeout(timeoutMs);
19
+ try {
20
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}/api/v1/agent/tenant/readiness?team=${encodeURIComponent(id)}`, {
21
+ redirect: "error",
22
+ signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
23
+ headers: { authorization: `Bearer ${bearer}`, accept: "application/json" },
24
+ });
25
+ await response.body?.cancel().catch(() => { });
26
+ }
27
+ catch {
28
+ // Unreachable or refused: the contract read below decides.
29
+ }
30
+ }
31
+ return loadContract(ctx, cfg, { refresh: true, signal });
32
+ }
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "./config.js";
2
- import { loadContract } from "./contract.js";
2
+ import { loadFreshTeamContract } from "./onboard-readiness-fresh.js";
3
3
  import { ONBOARD_DEFERRED_STEPS, onboardRequiredStepsWaiting, } from "./onboard.js";
4
4
  const evidenceNames = new Set(["account", "membershipId", "project", "team", "repository", "count", "checkedAt", "expiresAt", "revision", "lag", "cursor", "heartbeatAgeMs", "supervised", "available", "validated", "phaseStarted", "linearComment", "fleetActivity"]);
5
5
  const slug = (value) => value && /^[a-z][a-z0-9_]{1,63}$/.test(value) ? value : undefined;
@@ -98,7 +98,8 @@ export async function observeCloudOnboarding(ctx, scope = {}) {
98
98
  const cfg = loadConfig(ctx.home);
99
99
  if (!cfg?.user)
100
100
  return { checks: [{ id: "signin", state: "fail", required: true, reason: "personal_login_required" }], work: { state: "unknown" } };
101
- const { doc, source } = await loadContract(ctx, cfg, { refresh: true });
101
+ // CTC-4744: ask for each team's verdict fresh, the same read `catalyst setup`'s values step makes.
102
+ const { doc, source } = await loadFreshTeamContract(ctx, cfg, scope.teamIds ?? []);
102
103
  if (source === "cache")
103
104
  return { checks: [{ id: "cloud.setup", state: "unknown", required: true, reason: "cloud_observation_unavailable" }], work: { state: "unknown" } };
104
105
  const checks = [];
@@ -97,6 +97,17 @@ export async function readOnboardRepositoryInventory(ctx, journal, signal) {
97
97
  const teamId = selectedOnboardTeam(journal);
98
98
  if (!teamId)
99
99
  return { reason: "team_selection_unverified" };
100
+ // CTC-4742: the per-person repository list names every project a repository belongs to, so a
101
+ // team that uses several repositories shows all of them. A member's list is narrowed to their
102
+ // own access by the server. An older server without the route falls back to the one-per-team
103
+ // registry below.
104
+ const agent = await readExistingOnboardJson(ctx, "/api/v1/agent/repos", signal);
105
+ if (!sameIdentity())
106
+ return { reason: "repository_identity_unverified" };
107
+ if (!("reason" in agent))
108
+ return teamRepositories(ctx, journal, teamId, agent.body, sameIdentity, signal);
109
+ if (agent.status !== 404)
110
+ return { reason: "repository_read_unavailable" };
100
111
  const read = await readExistingOnboardJson(ctx, "/api/v1/repos", signal);
101
112
  if (!sameIdentity())
102
113
  return { reason: "repository_identity_unverified" };
@@ -171,6 +182,162 @@ export async function readOnboardRepositoryInventory(ctx, journal, signal) {
171
182
  }
172
183
  return { repositories };
173
184
  }
185
+ /** The saved login is still the person, account and origin this run started with (and, when
186
+ * `manager`, an owner or admin). Returns that config, or null. */
187
+ function journalIdentity(ctx, journal, manager = false) {
188
+ const cfg = loadConfig(ctx.home);
189
+ return cfg?.user &&
190
+ cfg.account === journal.account &&
191
+ cfg.user.id === journal.membershipId &&
192
+ !!journal.baseUrl &&
193
+ normalizeBaseUrl(cfg.baseUrl) === normalizeBaseUrl(journal.baseUrl) &&
194
+ (!manager || ["owner", "admin"].includes(cfg.user.role))
195
+ ? cfg
196
+ : null;
197
+ }
198
+ const REGISTER_PATH = "/api/v1/agent/project-repositories";
199
+ /** The fresh contract's repository IDs and the repositories registered to the chosen team (by its
200
+ * id), or why they can't be trusted. */
201
+ async function contractIds(ctx, journal, teamId, sameIdentity, signal) {
202
+ const read = await readExistingOnboardJson(ctx, "/api/v1/agent/contract", signal);
203
+ if (!sameIdentity())
204
+ return { reason: "repository_identity_unverified" };
205
+ if ("reason" in read)
206
+ return { reason: "repository_contract_unavailable" };
207
+ const doc = object(read.body);
208
+ const account = object(doc?.account);
209
+ const merge = object(doc?.merge);
210
+ if (!doc ||
211
+ account?.id !== journal.account ||
212
+ typeof doc.contractVersion !== "string" ||
213
+ contractVersionInRange(doc.contractVersion, readManifest().tenantContractRange) !== true ||
214
+ !merge ||
215
+ !Array.isArray(merge.repositories) ||
216
+ merge.repositories.length > 10_000 ||
217
+ !Array.isArray(doc.teams) ||
218
+ doc.teams.length > 1_000)
219
+ return { reason: "repository_contract_unverified" };
220
+ // The team's repositories, keyed by the team's id (a team key can be renamed or missing).
221
+ const teams = doc.teams.filter((value) => object(value)?.id === teamId);
222
+ const names = object(object(teams[0])?.repositories)?.registered;
223
+ if (teams.length !== 1 ||
224
+ !Array.isArray(names) ||
225
+ names.length > 1_000 ||
226
+ !names.every((name) => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(name)))
227
+ return { reason: "team_selection_unverified" };
228
+ const registered = new Set(names.map((name) => name.toLowerCase()));
229
+ const ids = new Map();
230
+ const idNames = new Map();
231
+ for (const value of merge.repositories) {
232
+ const row = object(value);
233
+ if (!row ||
234
+ typeof row.owner !== "string" ||
235
+ !part.test(row.owner) ||
236
+ typeof row.name !== "string" ||
237
+ !part.test(row.name) ||
238
+ !isRepositoryId(row.repoId))
239
+ return { reason: "repository_contract_unverified" };
240
+ const name = key({ owner: row.owner, name: row.name });
241
+ if (idNames.has(row.repoId) && idNames.get(row.repoId) !== name)
242
+ return { reason: "repository_contract_unverified" };
243
+ idNames.set(row.repoId, name);
244
+ ids.set(name, [...(ids.get(name) ?? []), row.repoId]);
245
+ }
246
+ // Registering is offered only when this account's contract lists the route (CTC-4742).
247
+ const canRegister = Array.isArray(doc.routes) &&
248
+ doc.routes.some((value) => {
249
+ const route = object(value);
250
+ return route?.method === "POST" && route.path === REGISTER_PATH;
251
+ });
252
+ return { registered, ids, canRegister };
253
+ }
254
+ /** CTC-4742: every repository an active project of the chosen team uses, with its contract ID. */
255
+ async function teamRepositories(ctx, journal, teamId, body, sameIdentity, signal) {
256
+ const list = object(body);
257
+ if (!list || !Array.isArray(list.repositories) || list.repositories.length > 10_000)
258
+ return { reason: "repository_inventory_shape" };
259
+ const contract = await contractIds(ctx, journal, teamId, sameIdentity, signal);
260
+ if ("reason" in contract)
261
+ return contract;
262
+ const repositories = [];
263
+ const seen = new Set();
264
+ for (const value of list.repositories) {
265
+ const row = object(value);
266
+ if (!row ||
267
+ typeof row.id !== "string" ||
268
+ typeof row.owner !== "string" ||
269
+ !part.test(row.owner) ||
270
+ typeof row.name !== "string" ||
271
+ !part.test(row.name))
272
+ return { reason: "repository_inventory_shape" };
273
+ // The contract says which repositories the team uses; this person's list says which of them
274
+ // they can see (the server narrows it for a member).
275
+ const name = key({ owner: row.owner, name: row.name });
276
+ if (!contract.registered.has(name))
277
+ continue;
278
+ if (seen.has(name))
279
+ return { reason: "repository_binding_ambiguous" };
280
+ seen.add(name);
281
+ const ids = contract.ids.get(name);
282
+ if (!ids || ids.length !== 1 || ids[0] !== row.id)
283
+ return { reason: "repository_id_unverified" };
284
+ repositories.push({ teamId, owner: row.owner, name: row.name, repoId: ids[0], registered: true });
285
+ }
286
+ return { repositories, canRegister: contract.canRegister };
287
+ }
288
+ /** CTC-4742: repositories the GitHub App can reach that the team does not use yet, for an owner or
289
+ * admin only (only they can register one). Any read problem means no offers, never a stopped step. */
290
+ async function offeredRepositories(ctx, journal, teamId, registered, signal) {
291
+ if (!journalIdentity(ctx, journal, true))
292
+ return [];
293
+ const read = await readExistingOnboardJson(ctx, "/api/v1/me/repositories/options", signal);
294
+ if (!journalIdentity(ctx, journal, true) || "reason" in read)
295
+ return [];
296
+ const github = object(object(read.body)?.github);
297
+ if (!github || github.connected !== true || !Array.isArray(github.repositories) || github.repositories.length > 10_000)
298
+ return [];
299
+ const used = new Set(registered.map(key));
300
+ const offers = [];
301
+ for (const value of github.repositories) {
302
+ const row = object(value);
303
+ if (!row || typeof row.owner !== "string" || !part.test(row.owner) || typeof row.name !== "string" || !part.test(row.name))
304
+ return [];
305
+ const name = key({ owner: row.owner, name: row.name });
306
+ if (used.has(name))
307
+ continue;
308
+ used.add(name);
309
+ offers.push({ teamId, owner: row.owner, name: row.name, repoId: null, registered: false });
310
+ }
311
+ return offers;
312
+ }
313
+ /** CTC-4742: register one repository to the team's project. Only adds; nothing here removes one. */
314
+ async function registerToTeam(ctx, journal, teamId, repository, signal) {
315
+ // The person who answered the question is the one who registers: checked right before the write.
316
+ const cfg = journalIdentity(ctx, journal, true);
317
+ if (!cfg)
318
+ return "repository_identity_unverified";
319
+ const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
320
+ const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
321
+ if (!bearer)
322
+ return "project_login_refresh_required";
323
+ // Bounded like every read here, so a server that never answers can't hold setup.
324
+ const deadline = AbortSignal.timeout(30_000);
325
+ const bounded = signal ? AbortSignal.any([signal, deadline]) : deadline;
326
+ try {
327
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${REGISTER_PATH}`, {
328
+ method: "POST",
329
+ redirect: "error",
330
+ signal: bounded,
331
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json" },
332
+ body: JSON.stringify({ teamId, repository }),
333
+ });
334
+ // A response is not evidence: the next read must show the repository on the team.
335
+ return response.status === 200 ? null : "repository_register_unverified";
336
+ }
337
+ catch {
338
+ return signal?.aborted ? "interrupted" : "repository_register_unverified";
339
+ }
340
+ }
174
341
  export function existingRepositoryAdapter(args, choose) {
175
342
  let chosen;
176
343
  const requested = (repositories, journal) => {
@@ -218,7 +385,7 @@ export function existingRepositoryAdapter(args, choose) {
218
385
  return {
219
386
  state: "done",
220
387
  evidence: {
221
- repository: JSON.stringify(rows),
388
+ repository: JSON.stringify(rows.map(({ teamId, owner, name, repoId }) => ({ teamId, owner, name, repoId }))),
222
389
  count: rows.length,
223
390
  checkedAt: ctx.now().getTime(),
224
391
  },
@@ -234,6 +401,14 @@ export function existingRepositoryAdapter(args, choose) {
234
401
  return waiting("repository_inventory_empty");
235
402
  if (!choose)
236
403
  return waiting("repository_choice_required");
404
+ const teamId = list.repositories[0].teamId;
405
+ const offers = list.canRegister
406
+ ? await offeredRepositories(ctx, journal, teamId, list.repositories, signal)
407
+ : [];
408
+ const choices = [
409
+ ...list.repositories.map((row) => ({ ...row })),
410
+ ...offers.map((row) => ({ ...row })),
411
+ ];
237
412
  let remove = () => { };
238
413
  const answer = await new Promise((resolve, reject) => {
239
414
  const stopped = () => resolve(null);
@@ -244,9 +419,8 @@ export function existingRepositoryAdapter(args, choose) {
244
419
  signal?.addEventListener("abort", stopped, { once: true });
245
420
  remove = () => signal?.removeEventListener("abort", stopped);
246
421
  Promise.resolve()
247
- .then(() => signal?.aborted
248
- ? null
249
- : choose(list.repositories.map((row) => ({ ...row }))))
422
+ // The hook gets copies: changing one can never change what is matched below.
423
+ .then(() => signal?.aborted ? null : choose(choices.map((row) => ({ ...row }))))
250
424
  .then(resolve, reject);
251
425
  }).finally(() => remove());
252
426
  if (!answer || signal?.aborted)
@@ -259,9 +433,26 @@ export function existingRepositoryAdapter(args, choose) {
259
433
  const names = answer.map((name) => name.toLowerCase());
260
434
  if (new Set(names).size !== names.length)
261
435
  return waiting("repository_selection_unverified");
262
- const rows = names.map((name) => list.repositories.find((row) => key(row) === name));
263
- if (!rows.every((row) => !!row))
436
+ const picked = names.map((name) => choices.find((row) => key(row) === name));
437
+ if (!picked.every((row) => !!row))
264
438
  return waiting("repository_selection_unverified");
439
+ // CTC-4742: a selected offer is registered to the team. Leaving a registered repository
440
+ // unselected never unregisters it; nothing here removes a repository.
441
+ for (const row of picked) {
442
+ if (row.registered !== false)
443
+ continue;
444
+ const failed = await registerToTeam(ctx, journal, teamId, `${row.owner}/${row.name}`, signal);
445
+ if (failed)
446
+ return waiting(failed);
447
+ }
448
+ const fresh = picked.some((row) => row.registered === false)
449
+ ? await readOnboardRepositoryInventory(ctx, journal, signal)
450
+ : list;
451
+ if ("reason" in fresh)
452
+ return waiting(fresh.reason);
453
+ const rows = names.map((name) => fresh.repositories.find((row) => key(row) === name));
454
+ if (!rows.every((row) => !!row))
455
+ return waiting("repository_registration_visibility_pending");
265
456
  chosen = rows;
266
457
  return check(ctx, journal, signal);
267
458
  },
@@ -339,7 +339,11 @@ export function createOnboardRuntime(args, ctx, hooks) {
339
339
  },
340
340
  },
341
341
  housekeeping: onboardHousekeepingAdapter(hooks.scheduler),
342
- values: onboardValuesAdapter(),
342
+ // CTC-4744: a settings read the cloud has not finished is waited on here, on one line.
343
+ values: onboardValuesAdapter({
344
+ message: (text) => hooks.ui?.note ? hooks.ui.note(text) : hooks.ui ? hooks.ui.message(text) : ctx.stderr(text),
345
+ sleep: hooks.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
346
+ }),
343
347
  // Only a person at a terminal picks a ticket; --yes, JSON and headless runs leave it waiting.
344
348
  "first-ticket": onboardFirstTicketAdapter(interactiveWait && hooks.ui?.chooseFirstTicket
345
349
  ? {
@@ -1,5 +1,8 @@
1
1
  import { setupPartHeading, setupStepView, SETUP_NUMBERS, SETUP_PART_NUMBERS, SETUP_PART_OF, } from "./setup-onboard-copy.js";
2
2
  export const COMPUTER_CHECKS = ["machine", "cli", "skills", "legacy"];
3
+ /** The runner row when no runner was chosen up front, naming where that choice came from, so the
4
+ * plan says why no question comes (CTC-4739). */
5
+ const runnerDeclined = (by) => `this computer will not take work, because ${by}`;
3
6
  const PLAN = [
4
7
  ["On this computer", "signin", "you approve once in your browser"],
5
8
  ["On this computer", "housekeeping", "schedule the daily update"],
@@ -22,7 +25,14 @@ const PLAN = [
22
25
  * part. `json` is the plain-text plan an agent reads beside `--json` (dry run, --json on stderr,
23
26
  * headless): the older groups and the numbers JSON `actions[].number` reports (CTC-4680).
24
27
  */
25
- export function standalonePlan(journal, scope, runnerSelected = false, numbering = "parts") {
28
+ export function standalonePlan(journal, scope,
29
+ /** `true` for --runner, `false` for --no-runner, absent when setup will ask (CTC-4739). */
30
+ runner, numbering = "parts",
31
+ /** Why `runner` is false: the flag on an interactive run, the headless input otherwise. */
32
+ declinedBy = "--no-runner was passed") {
33
+ const runnerSelected = runner === true;
34
+ const RUNNER_DECLINED = runnerDeclined(declinedBy);
35
+ const declined = runner === false;
26
36
  const json = numbering === "json";
27
37
  const includes = (id) => !scope || scope.includes(id);
28
38
  const steps = new Map(journal.steps.map((step) => [step.id, step]));
@@ -53,7 +63,9 @@ export function standalonePlan(journal, scope, runnerSelected = false, numbering
53
63
  if (includes("runner"))
54
64
  row("runner", runnerSelected
55
65
  ? "start a Catalyst runner here with Docker"
56
- : "optional; this computer takes work only if you choose it");
66
+ : declined
67
+ ? RUNNER_DECLINED
68
+ : "optional; this computer takes work only if you choose it");
57
69
  continue;
58
70
  }
59
71
  // The terminal gives repository values their own row; the plain-text plan keeps them folded
@@ -66,9 +78,13 @@ export function standalonePlan(journal, scope, runnerSelected = false, numbering
66
78
  continue;
67
79
  const reviewedDetail = id === "capacity" && includes("runner") && runnerSelected
68
80
  ? "start a Catalyst runner here with Docker"
69
- : id === "capacity" && !includes("capacity")
70
- ? "review the optional runner on this machine; start only if selected"
71
- : detail;
81
+ : id === "capacity" && includes("runner") && declined
82
+ ? includes("capacity")
83
+ ? `${detail}; ${RUNNER_DECLINED}`
84
+ : RUNNER_DECLINED
85
+ : id === "capacity" && !includes("capacity")
86
+ ? "review the optional runner on this machine; start only if selected"
87
+ : detail;
72
88
  rows.push({
73
89
  group: json ? group : setupPartHeading(SETUP_PART_OF[id], false),
74
90
  part: SETUP_PART_OF[id],
@@ -1,13 +1,19 @@
1
1
  import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
2
2
  import { trackedPrompt } from "./setup-prompt-frame.js";
3
3
  import { createOnboardInterrupts } from "./onboard-interrupts.js";
4
- import { setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
4
+ import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
5
5
  import { createOnboardProgress, } from "./onboard-progress.js";
6
6
  import { CREATE_TEAM_CHOICE, suggestTeamKey, teamKeyProblem, teamNameProblem, } from "./onboard-team-create.js";
7
7
  import { FIRST_TICKET_SAMPLE, FIRST_TICKET_SKIP, } from "./onboard-first-ticket.js";
8
8
  import { ONBOARD_STEPS, ONBOARD_TITLES, stepSatisfied, onboardHeaderIdentity, onboardIdentityLines, onboardNextActions, onboardReadyForWork, onboardStepAction, } from "./onboard.js";
9
9
  import { onboardStepDetail, onboardReasonText } from "./onboard-next.js";
10
10
  import { CONNECT_PAGE_PATH } from "./onboard-checklist.js";
11
+ /** What the runner question's answers mean, said before it is asked (CTC-4739). */
12
+ const RUNNER_EXPLAINED = [
13
+ "Yes makes this computer pick up tickets and run agents in Docker with your workspace's AI accounts. It uses this computer's CPU and memory while it is on, and turning it off or letting it sleep pauses that work.",
14
+ "No is right for a personal workstation: your workspace's runner hosts do the work.",
15
+ "Pass --no-runner or --runner to skip this question next time.",
16
+ ];
11
17
  export function shouldUseOnboardUi(args, stdoutTty) {
12
18
  return (stdoutTty &&
13
19
  !args.json &&
@@ -254,8 +260,11 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
254
260
  return `Open ${connectWaits.get(action.id)} and ${what}.`;
255
261
  };
256
262
  const live = screen.actions.filter((action) => step(action.id).reason !== "interrupted");
257
- const theirs = live.filter((action) => setupWaitsOnAdmin(step(action.id), journal));
258
- const mine = live.filter((action) => !theirs.includes(action));
263
+ // CTC-4744: what only the cloud's own work holds up is neither the person's nor an admin's.
264
+ const working = live.filter((action) => CLOUD_WORK.has(step(action.id).reason ?? ""));
265
+ const theirs = live.filter((action) => !working.includes(action) &&
266
+ setupWaitsOnAdmin(step(action.id), journal));
267
+ const mine = live.filter((action) => !theirs.includes(action) && !working.includes(action));
259
268
  // Steps only an owner or admin can do, less any already listed as their task above.
260
269
  const skipped = journal.steps.filter((s) => s.reason === "member_scope" &&
261
270
  setupWaitsOnAdmin(s, journal) &&
@@ -272,7 +281,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
272
281
  : waiting
273
282
  ? "Next: run catalyst setup once they have."
274
283
  : "Next: run catalyst setup to check again.";
275
- return { screen, mine, theirs, skipped, waiting, next, text, live };
284
+ return { screen, mine, theirs, working, skipped, waiting, next, text, live };
276
285
  };
277
286
  const summary = (journal) => {
278
287
  const sorted = sortActions(journal);
@@ -293,6 +302,11 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
293
302
  if (screen.more)
294
303
  renderer.line(`and ${screen.more} more after these`);
295
304
  }
305
+ if (sorted.working.length) {
306
+ renderer.line("Still in progress, nothing for you to do:");
307
+ for (const action of sorted.working)
308
+ renderer.item("· ", sorted.text(action));
309
+ }
296
310
  if (sorted.waiting) {
297
311
  renderer.line("Waiting on an owner or admin of your Catalyst workspace:");
298
312
  for (const action of theirs)
@@ -543,16 +557,35 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
543
557
  ...(prompts.multiselect
544
558
  ? {
545
559
  async chooseRepositories(repositories) {
560
+ const projectsPage = () => {
561
+ try {
562
+ const base = deps.baseUrl?.();
563
+ return base ? ` (${new URL("/settings/projects", base).href})` : "";
564
+ }
565
+ catch {
566
+ return "";
567
+ }
568
+ };
546
569
  stop();
547
570
  if (!repositories.length || abort.signal.aborted)
548
571
  return null;
572
+ // CTC-4742: what the team already uses starts selected. Unselecting never removes one.
573
+ const selected = repositories
574
+ .filter((row) => row.registered === true)
575
+ .map((row) => `${row.owner}/${row.name}`);
549
576
  const answer = await multiselect({
550
577
  ...options,
551
- message: "Which repositories should use this Linear team?",
578
+ message: selected.length
579
+ ? `Which repositories should use this Linear team? Leaving a repository unselected does not remove it from this team; an admin removes one in Settings → Projects${projectsPage()}.`
580
+ : "Which repositories should use this Linear team?",
552
581
  required: true,
582
+ ...(selected.length ? { initialValues: selected } : {}),
553
583
  options: repositories.map((row) => ({
554
584
  value: `${row.owner}/${row.name}`,
555
585
  label: `${row.owner}/${row.name}`,
586
+ ...(row.registered === false
587
+ ? { hint: "not used by this team yet; selecting it adds it" }
588
+ : {}),
556
589
  })),
557
590
  });
558
591
  if (prompts.isCancel(answer)) {
@@ -636,9 +669,21 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
636
669
  return typeof answer === "string" ? answer : null;
637
670
  },
638
671
  async chooseRunner() {
639
- stop();
640
672
  if (abort.signal.aborted)
641
673
  return null;
674
+ // CTC-4739: say what each answer does to this computer before asking, and how to skip it.
675
+ stop();
676
+ if (renderer) {
677
+ renderer.begin(number("runner"), title("runner"), "", "ask");
678
+ for (const line of RUNNER_EXPLAINED)
679
+ renderer.detail(line);
680
+ active = true;
681
+ questionOpen = true;
682
+ }
683
+ else
684
+ prompts.log.message(RUNNER_EXPLAINED.join("\n"), {
685
+ output: streams.output,
686
+ });
642
687
  const answer = await select({
643
688
  ...options,
644
689
  message: "Run Catalyst's work on this machine?",
@@ -857,22 +902,33 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
857
902
  }
858
903
  },
859
904
  async checkAgain(journal) {
905
+ const sorted = sortActions(journal);
860
906
  if (!renderer ||
861
907
  !interactive ||
862
908
  abort.signal.aborted ||
863
- !sortActions(journal).mine.length)
909
+ (!sorted.mine.length && !sorted.working.length))
864
910
  return false;
865
911
  active = false;
866
912
  summary(journal);
867
913
  summaryShown = true;
914
+ // CTC-4744: one question, about one thing. When the person has something to do, checking
915
+ // again is for after they do it; when only the cloud is working, it is a wait.
916
+ const yours = sorted.mine.length > 0;
868
917
  const answer = await select({
869
918
  ...options,
870
- message: "Done with these?",
919
+ message: yours
920
+ ? `Check again once ${sorted.mine.length === 1 ? "that's" : "those are"} done?`
921
+ : "Catalyst is still reading the repositories' settings. Wait for it?",
871
922
  initialValue: "again",
872
- options: [
873
- { value: "again", label: "Check again now" },
874
- { value: "stop", label: "Stop here" },
875
- ],
923
+ options: yours
924
+ ? [
925
+ { value: "again", label: "Check again now" },
926
+ { value: "stop", label: "Finish now; run catalyst setup later to check again" },
927
+ ]
928
+ : [
929
+ { value: "again", label: "Wait and check again (about 1 minute)" },
930
+ { value: "stop", label: "Finish now; check later with catalyst ready" },
931
+ ],
876
932
  }, true);
877
933
  if (prompts.isCancel(answer)) {
878
934
  abort.abort();
@@ -1,47 +1,152 @@
1
1
  import { loadConfig } from "./config.js";
2
- import { loadContract } from "./contract.js";
3
2
  import { selectedOnboardTeam } from "./onboard-existing.js";
3
+ import { loadFreshTeamContract } from "./onboard-readiness-fresh.js";
4
4
  import { valuesFacts } from "./ready-copy.js";
5
5
  const waiting = (reason, evidence) => ({
6
6
  state: "waiting",
7
7
  reason,
8
8
  ...(evidence ? { evidence } : {}),
9
9
  });
10
+ /** How long setup waits for the cloud to read a repository's settings, and how often it looks. */
11
+ export const VALUES_WAIT_MS = 60_000;
12
+ const VALUES_POLL_MS = 10_000;
13
+ /** While waiting, one slow read-through can't stretch "up to 1 minute". */
14
+ const WAIT_READ_TIMEOUT_MS = 10_000;
15
+ /** The repositories a team's verdict covers, by name, for the one waiting line. */
16
+ function teamRepositories(team, check) {
17
+ const fromCheck = check ? (valuesFacts(check).repos ?? []).map((row) => row.repo) : [];
18
+ if (fromCheck.length)
19
+ return fromCheck;
20
+ const registered = team?.repositories
21
+ ?.registered;
22
+ return Array.isArray(registered)
23
+ ? registered.filter((name) => typeof name === "string")
24
+ : [];
25
+ }
26
+ function named(repositories) {
27
+ if (!repositories.length)
28
+ return "this team's repositories";
29
+ if (repositories.length === 1)
30
+ return repositories[0];
31
+ return `${repositories[0]} and ${repositories.length - 1} more`;
32
+ }
10
33
  /** The selected team's `required_values` readiness check decides this step. It passes when the
11
34
  * selected repositories declare no required value, or every one has a value. Nothing here reads,
12
- * imports or prints a value: a missing one is named, with the page where it is set. A stale, absent
13
- * or unknown check waits, so the step never claims values it could not see. */
14
- export function onboardValuesAdapter() {
35
+ * imports or prints a value: a missing one is named, with the page where it is set.
36
+ *
37
+ * CTC-4744: the verdict is asked for fresh first (it lasts five minutes and only a readiness read
38
+ * recomputes it). When the cloud has not read the settings yet, setup waits up to a minute on one
39
+ * line, instead of telling the person to run setup again. */
40
+ export function onboardValuesAdapter(deps = {}) {
15
41
  return {
16
42
  check: async (ctx, journal, signal) => {
17
43
  const cfg = loadConfig(ctx.home);
18
44
  const teamId = selectedOnboardTeam(journal);
19
45
  if (!cfg || !teamId)
20
46
  return waiting("required_values_unverified");
21
- let loaded;
22
- try {
23
- loaded = await loadContract(ctx, cfg, { refresh: true, signal });
24
- }
25
- catch {
26
- return waiting(signal?.aborted ? "interrupted" : "required_values_unverified");
47
+ const read = async (timeoutMs, readSignal = signal) => {
48
+ let loaded;
49
+ try {
50
+ // The contract transport owns another timeout. Carry our caller's cancellation through
51
+ // its fetch too, including response-body reads and any credential refresh.
52
+ const readCtx = readSignal
53
+ ? {
54
+ ...ctx,
55
+ fetch: (input, init) => ctx.fetch(input, {
56
+ ...init,
57
+ signal: init?.signal
58
+ ? AbortSignal.any([readSignal, init.signal])
59
+ : readSignal,
60
+ }),
61
+ }
62
+ : ctx;
63
+ loaded = await loadFreshTeamContract(readCtx, cfg, [teamId], readSignal, timeoutMs);
64
+ }
65
+ catch {
66
+ return { kind: "unverified" };
67
+ }
68
+ if (loaded.source === "cache")
69
+ return { kind: "unverified" };
70
+ const team = loaded.doc.teams.find((row) => row.id === teamId);
71
+ const readiness = team?.readiness;
72
+ const fresh = readiness?.checkedAt != null &&
73
+ readiness.expiresAt != null &&
74
+ readiness.expiresAt > ctx.now().getTime();
75
+ // A verdict the cloud could not bring up to date says nothing either way.
76
+ if (!readiness || !fresh)
77
+ return { kind: "unverified" };
78
+ const check = readiness.checks.find((row) => row.id === "required_values");
79
+ // An older cloud may not send this check at all: that says nothing, it is not an unread.
80
+ if (!check)
81
+ return { kind: "unverified" };
82
+ if (check.state === "pass")
83
+ return { kind: "pass" };
84
+ if (check.state === "fail")
85
+ return { kind: "fail", check };
86
+ return { kind: "unread", repositories: teamRepositories(team, check) };
87
+ };
88
+ let reading = await read();
89
+ if (reading.kind === "unread" && deps.sleep) {
90
+ deps.message?.(`Waiting for Catalyst to read the settings of ${named(reading.repositories)} (up to 1 minute)…`);
91
+ // One deadline covers sleep, readiness refresh and contract/body reads. Attempts also
92
+ // bound an injected sleep whose clock stands still.
93
+ const deadline = Date.now() + VALUES_WAIT_MS;
94
+ const owned = new AbortController();
95
+ const waitSignal = signal ? AbortSignal.any([signal, owned.signal]) : owned.signal;
96
+ let stop = () => { };
97
+ const stopped = new Promise((resolve) => {
98
+ stop = () => {
99
+ owned.abort();
100
+ resolve(null);
101
+ };
102
+ });
103
+ const timer = setTimeout(stop, VALUES_WAIT_MS);
104
+ signal?.addEventListener("abort", stop, { once: true });
105
+ try {
106
+ if (signal?.aborted)
107
+ stop();
108
+ for (let attempt = 0; reading.kind === "unread" &&
109
+ attempt < VALUES_WAIT_MS / VALUES_POLL_MS &&
110
+ Date.now() < deadline &&
111
+ !waitSignal.aborted; attempt++) {
112
+ // Ctrl-C or expiry ends either stage at once, even if an injected promise stalls.
113
+ await Promise.race([
114
+ deps.sleep(Math.min(VALUES_POLL_MS, Math.max(0, deadline - Date.now()))),
115
+ stopped,
116
+ ]);
117
+ if (waitSignal.aborted || Date.now() >= deadline)
118
+ break;
119
+ const next = await Promise.race([
120
+ read(Math.min(WAIT_READ_TIMEOUT_MS, deadline - Date.now()), waitSignal),
121
+ stopped,
122
+ ]);
123
+ // A result after expiry says nothing about the last observed unread verdict.
124
+ if (waitSignal.aborted || Date.now() >= deadline || !next)
125
+ break;
126
+ reading = next;
127
+ }
128
+ }
129
+ finally {
130
+ clearTimeout(timer);
131
+ signal?.removeEventListener("abort", stop);
132
+ owned.abort();
133
+ }
27
134
  }
28
- if (loaded.source === "cache")
29
- return waiting("required_values_unverified");
30
- const readiness = loaded.doc.teams.find((team) => team.id === teamId)?.readiness;
31
- const fresh = readiness?.checkedAt != null &&
32
- readiness.expiresAt != null &&
33
- readiness.expiresAt > ctx.now().getTime();
34
- const check = fresh
35
- ? readiness.checks.find((row) => row.id === "required_values")
36
- : undefined;
37
- if (check?.state === "pass")
135
+ if (signal?.aborted)
136
+ return waiting("interrupted");
137
+ if (reading.kind === "pass")
38
138
  return { state: "done", evidence: { team: teamId } };
39
- if (check?.state !== "fail")
40
- return waiting("required_values_unverified");
41
- return waiting("required_values_missing", {
42
- team: teamId,
43
- requiredValues: JSON.stringify(valuesFacts(check)),
44
- });
139
+ if (reading.kind === "fail")
140
+ return waiting("required_values_missing", {
141
+ team: teamId,
142
+ requiredValues: JSON.stringify(valuesFacts(reading.check)),
143
+ });
144
+ if (reading.kind === "unread")
145
+ return waiting("required_values_unread", {
146
+ team: teamId,
147
+ repositories: named(reading.repositories),
148
+ });
149
+ return waiting("required_values_unverified");
45
150
  },
46
151
  };
47
152
  }
package/dist/onboard.js CHANGED
@@ -135,6 +135,8 @@ export const ONBOARD_DEPENDENCIES = {
135
135
  "linear.automations",
136
136
  "accounts",
137
137
  "capacity",
138
+ // CTC-4744: no work is offered while the cloud is still reading the settings (dependencySettled).
139
+ "values",
138
140
  ],
139
141
  };
140
142
  const ADMIN_STEPS = new Set([
@@ -600,7 +602,7 @@ function printPlan(ctx, journal, identity, options) {
600
602
  ctx.stdout(line);
601
603
  let group;
602
604
  // Beside --json and headless output: the numbers JSON actions carry (CTC-4680).
603
- for (const row of standalonePlan(journal, options?.scope, options?.runner, "json")) {
605
+ for (const row of standalonePlan(journal, options?.scope, options?.runner, "json", options?.runnerDeclinedBy)) {
604
606
  if (row.group !== group) {
605
607
  ctx.stdout("");
606
608
  ctx.stdout(row.group);
@@ -652,6 +654,18 @@ function sanitizedResult(id, result, at) {
652
654
  row.at = at;
653
655
  return row;
654
656
  }
657
+ /** CTC-4744: whether a step a later one depends on has settled. Only the cloud actively reading
658
+ * the settings (`required_values_unread`, after setup's own one-minute wait) holds a first
659
+ * ticket. A finished check that found missing values has settled: the first phase needs no
660
+ * values, and the missing ones are named. So has one that could not be checked at all (an older
661
+ * cloud, a cached contract): holding work on it would hold it for good. */
662
+ export function dependencySettled(step) {
663
+ return (stepSatisfied(step) ||
664
+ (step?.id === "values" &&
665
+ step.state === "waiting" &&
666
+ step.reason !== "required_values_unread" &&
667
+ step.reason !== "interrupted"));
668
+ }
655
669
  export function stepSatisfied(step) {
656
670
  return (step?.state === "done" ||
657
671
  (step?.state === "skipped" &&
@@ -797,7 +811,15 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
797
811
  for (const id of ONBOARD_STEPS)
798
812
  reviewedScope.add(id);
799
813
  const planOptions = (journal) => ({
800
- runner: args.flags.runner === true,
814
+ runner: args.flags.runner === true
815
+ ? true
816
+ : args.flags["no-runner"] === true
817
+ ? false
818
+ : undefined,
819
+ // Headless turns --runner no and CATALYST_ONBOARD_RUNNER=no into --no-runner; say which.
820
+ runnerDeclinedBy: args.flags.headless === true
821
+ ? "the headless runner input is no"
822
+ : "--no-runner was passed",
801
823
  localSync: args.flags["local-sync"] === true || journal.localSync === true,
802
824
  scope: ONBOARD_STEPS.filter((id) => reviewedScope.has(id)),
803
825
  });
@@ -1307,7 +1329,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1307
1329
  deps.ui?.stepEnd(journalStep(journal, id), journal);
1308
1330
  continue;
1309
1331
  }
1310
- const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
1332
+ const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !dependencySettled(journalStep(journal, parent)));
1311
1333
  if (missing) {
1312
1334
  recordStep({
1313
1335
  id,
@@ -299,8 +299,8 @@ export function teamCheckCopy(baseUrl, key, id, state, valuesFix) {
299
299
  case "required_values":
300
300
  return unknown
301
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",
302
+ line: team(`Catalyst hasn't finished reading the settings of ${key}'s repositories yet.`),
303
+ fix: "nothing to do; Catalyst reads them on its own. Run catalyst ready again in a minute to see the result",
304
304
  }
305
305
  : {
306
306
  line: team(`A variable ${key}'s repository needs has no value, so Catalyst can't run its work.`),
@@ -1,4 +1,4 @@
1
- import { ONBOARD_DEPENDENCIES, ONBOARD_STEPS, ONBOARD_TITLES, } from "./onboard.js";
1
+ import { dependencySettled, ONBOARD_DEPENDENCIES, ONBOARD_STEPS, ONBOARD_TITLES, } from "./onboard.js";
2
2
  import { dispatchStageName, onboardReasonText } from "./onboard-next.js";
3
3
  /** The step numbers `--json` reports. People read SETUP_PART_NUMBERS instead (CTC-4680). */
4
4
  export const SETUP_NUMBERS = {
@@ -250,6 +250,8 @@ function unfinished(step) {
250
250
  return step.state === "skipped" ? "not chosen" : "not done yet";
251
251
  }
252
252
  }
253
+ /** CTC-4744: reasons a step waits on the cloud's own work, never on the person or an admin. */
254
+ export const CLOUD_WORK = new Set(["required_values_unread"]);
253
255
  export function setupStepView(step, journal, required = false) {
254
256
  required ||= step.id === "runner" && step.evidence?.selected === true;
255
257
  let mark = step.state === "done"
@@ -265,12 +267,16 @@ export function setupStepView(step, journal, required = false) {
265
267
  ? "skip"
266
268
  : "act";
267
269
  let outcome = step.state === "done" ? succeeded(step) : unfinished(step);
268
- if (mark === "later") {
270
+ if (step.state !== "done" && CLOUD_WORK.has(step.reason ?? "")) {
271
+ mark = "later";
272
+ outcome = "still in progress; Catalyst is reading the settings";
273
+ }
274
+ else if (mark === "later") {
269
275
  const waitsOn = step.reason === "github_install_pending"
270
276
  ? ["github.install"]
271
277
  : (ONBOARD_DEPENDENCIES[step.id] ?? []).filter((id) => {
272
278
  const s = journal?.steps.find((s) => s.id === id);
273
- return s?.state !== "done" && s?.state !== "skipped";
279
+ return s?.state !== "skipped" && !dependencySettled(s);
274
280
  });
275
281
  // Numbers restart in each part: this part's steps come bare, then each other part once.
276
282
  const byPart = new Map();
@@ -361,7 +367,7 @@ export function setupPartProgress(journal, scope,
361
367
  /** "run" before readiness is known: a member's admin skips count aside, neither done nor owed.
362
368
  * At the end they count done only when readiness passed. */
363
369
  moment = "end") {
364
- const empty = () => ({ total: 0, done: 0, needs: 0, failed: 0, admin: 0, aside: 0 });
370
+ const empty = () => ({ total: 0, done: 0, needs: 0, working: 0, failed: 0, admin: 0, aside: 0 });
365
371
  const progress = { 1: empty(), 2: empty(), 3: empty() };
366
372
  const readinessPassed = journal.steps.find((s) => s.id === "ready")?.state === "done";
367
373
  const rows = new Map();
@@ -379,6 +385,7 @@ moment = "end") {
379
385
  {
380
386
  // A member's own skip (no --team) is theirs to finish, so it is never counted done.
381
387
  mark: memberOwnSkip(step) ? "act" : setupStepView(step, journal).mark,
388
+ working: step.state !== "done" && CLOUD_WORK.has(step.reason ?? ""),
382
389
  kind: setupWaitsOnAdmin(step, journal)
383
390
  ? "admin"
384
391
  : step.reason === "member_scope" &&
@@ -400,6 +407,8 @@ moment = "end") {
400
407
  if (own.some((s) => s.mark === "fail"))
401
408
  part.failed++;
402
409
  }
410
+ else if (own.some((s) => s.working))
411
+ part.working++;
403
412
  else if (steps.some((s) => s.kind === "admin"))
404
413
  part.admin++;
405
414
  else if (steps.some((s) => s.kind === "aside"))
@@ -445,6 +454,8 @@ export function setupTrackerRows(progress, moment) {
445
454
  return row("done", "");
446
455
  if (got.needs)
447
456
  return row(needsMark, `${steps(got.needs)} ${got.needs === 1 ? "needs" : "need"} someone`);
457
+ if (got.working)
458
+ return row("later", `${steps(got.working)} still in progress`);
448
459
  if (got.admin)
449
460
  return row("act", waiting(got.admin));
450
461
  if (got.aside)
@@ -532,10 +543,13 @@ export function setupFinalScreen(journal, base, paused = false, required = new S
532
543
  const roots = journal.steps
533
544
  .filter((step) => {
534
545
  const m = setupStepView(step, journal, required.has(step.id)).mark;
535
- return ((m === "act" || m === "fail" || adminLeft.has(step.id)) &&
546
+ return ((m === "act" ||
547
+ m === "fail" ||
548
+ adminLeft.has(step.id) ||
549
+ (step.state !== "done" && CLOUD_WORK.has(step.reason ?? ""))) &&
536
550
  (ONBOARD_DEPENDENCIES[step.id] ?? []).every((id) => {
537
551
  const parent = journal.steps.find((s) => s.id === id);
538
- return parent?.state === "done" || parent?.state === "skipped";
552
+ return parent?.state === "skipped" || dependencySettled(parent);
539
553
  }));
540
554
  })
541
555
  .filter((step) => step.id !== "ready")
@@ -545,6 +559,8 @@ export function setupFinalScreen(journal, base, paused = false, required = new S
545
559
  const actions = unique
546
560
  .slice(0, 5)
547
561
  .map((step) => ({ id: step.id, text: action(step, base, journal) }));
562
+ // CTC-4744: the Next line is about what the person does; the cloud's own work is listed apart.
563
+ const personal = actions.filter((row) => !CLOUD_WORK.has(journal.steps.find((s) => s.id === row.id)?.reason ?? ""));
548
564
  const readiness = journal.steps.find((step) => step.id === "ready");
549
565
  const readinessCause = !paused &&
550
566
  !actions.length &&
@@ -578,19 +594,21 @@ export function setupFinalScreen(journal, base, paused = false, required = new S
578
594
  ? `Follow ${ticketUrl ?? key} in Linear; Catalyst comments there as each phase finishes.`
579
595
  : heading === "Setup complete"
580
596
  ? `Next: move a ticket to ${dispatchStageName(firstTicket?.evidence?.stage)} in Linear.`
581
- : journal.exit === 0 && actions[0]
582
- ? `Next: ${actions[0].text[0].toLowerCase() + actions[0].text.slice(1)}`
583
- : actions.length === 1
597
+ : journal.exit === 0 && personal[0]
598
+ ? `Next: ${personal[0].text[0].toLowerCase() + personal[0].text.slice(1)}`
599
+ : personal.length === 1
584
600
  ? "Next: run catalyst onboard after you finish 1."
585
- : actions.length === 2
601
+ : personal.length === 2
586
602
  ? "Next: run catalyst onboard after you finish 1 and 2."
587
- : actions.length
603
+ : personal.length
588
604
  ? "Next: run catalyst onboard after you finish 1 to " +
589
- actions.length +
605
+ personal.length +
590
606
  "."
591
- : readyStep?.reason === "member_team_required"
592
- ? "Next: run catalyst onboard --team <KEY> to check again."
593
- : "Next: run catalyst onboard to check again.";
607
+ : actions.length
608
+ ? "Next: nothing for you to do. catalyst ready shows when Catalyst has read the settings."
609
+ : readyStep?.reason === "member_team_required"
610
+ ? "Next: run catalyst onboard --team <KEY> to check again."
611
+ : "Next: run catalyst onboard to check again.";
594
612
  return {
595
613
  heading,
596
614
  readinessCause,
@@ -696,31 +714,34 @@ export function onboardJsonView(journal, base, paused = false, options = {}) {
696
714
  number: SETUP_NUMBERS[action.id] ?? 0,
697
715
  text: text.charAt(0).toUpperCase() + text.slice(1),
698
716
  ...(url ? { url } : {}),
699
- who: admin
700
- ? "admin"
701
- : action.id === "github.install"
702
- ? "github-org-admin"
703
- : [
704
- "linear.personal",
705
- "github.personal",
706
- "signin",
707
- "linear.team",
708
- "github.repos",
709
- "projects",
710
- "first-ticket",
711
- "settings",
712
- "values",
713
- "accounts",
714
- "runner",
715
- "machine",
716
- "cli",
717
- "skills",
718
- "legacy",
719
- "daemon",
720
- "housekeeping",
721
- ].includes(action.id)
722
- ? "you"
723
- : "admin",
717
+ // CTC-4744: the cloud's own work is nobody's to do.
718
+ who: CLOUD_WORK.has(step.reason ?? "")
719
+ ? "catalyst"
720
+ : admin
721
+ ? "admin"
722
+ : action.id === "github.install"
723
+ ? "github-org-admin"
724
+ : [
725
+ "linear.personal",
726
+ "github.personal",
727
+ "signin",
728
+ "linear.team",
729
+ "github.repos",
730
+ "projects",
731
+ "first-ticket",
732
+ "settings",
733
+ "values",
734
+ "accounts",
735
+ "runner",
736
+ "machine",
737
+ "cli",
738
+ "skills",
739
+ "legacy",
740
+ "daemon",
741
+ "housekeeping",
742
+ ].includes(action.id)
743
+ ? "you"
744
+ : "admin",
724
745
  };
725
746
  });
726
747
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
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",
@@ -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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — 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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — 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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — 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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — 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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — 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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — 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.4 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.15.6 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10