@catalyst-cloud/cli 0.9.3 → 0.9.5

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 (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +35 -31
  3. package/dist/args.js +13 -1
  4. package/dist/ask.js +2 -2
  5. package/dist/capabilities.js +70 -0
  6. package/dist/cli.js +37 -29
  7. package/dist/config.js +1 -1
  8. package/dist/connections.js +5 -5
  9. package/dist/environment.js +4 -4
  10. package/dist/events.js +1 -1
  11. package/dist/execution.js +5 -4
  12. package/dist/identity.js +3 -3
  13. package/dist/oauth.js +3 -3
  14. package/dist/query.js +27 -1
  15. package/dist/ready.js +3 -3
  16. package/dist/release.js +1 -1
  17. package/dist/replica.js +9 -7
  18. package/dist/runtime-store.js +2 -2
  19. package/dist/runtime.js +1 -1
  20. package/dist/secret.js +1 -1
  21. package/dist/skills.js +1 -1
  22. package/dist/team.js +329 -0
  23. package/package.json +1 -1
  24. package/skills/catalyst-github/SKILL.md +1 -1
  25. package/skills/catalyst-linear/SKILL.md +1 -1
  26. package/skills/catalyst-onboard/SKILL.md +51 -35
  27. package/skills/catalyst-onboard/references/choosing-a-coding-account.md +52 -0
  28. package/skills/catalyst-onboard/references/declaring-a-repository.md +4 -2
  29. package/skills/catalyst-onboard/references/local-sync.md +3 -3
  30. package/skills/catalyst-onboard/references/replacing-a-credential.md +2 -2
  31. package/skills/catalyst-onboard/references/skill-sources.md +1 -1
  32. package/skills/catalyst-onboard/references/the-one-path.md +54 -86
  33. package/skills/catalyst-onboard/references/what-a-phase-needs.md +6 -6
  34. package/skills/catalyst-onboard/references/what-the-browser-owns.md +8 -10
  35. package/skills/catalyst-onboard/references/who-fixes-what.md +38 -18
  36. package/skills/catalyst-onboard/scripts/lib/cli.mjs +15 -1
  37. package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
  38. package/skills/catalyst-onboard/scripts/where-am-i.mjs +294 -61
  39. package/skills/catalyst-setup/SKILL.md +1 -1
  40. package/skills/connect-me/SKILL.md +1 -1
  41. package/skills/how-catalyst-works/SKILL.md +2 -2
  42. package/skills/how-catalyst-works/references/what-runs-next.md +11 -0
  43. package/skills/run-this-project/SKILL.md +1 -1
  44. package/skills/unstick/SKILL.md +1 -1
  45. package/skills/what-needs-me/SKILL.md +1 -1
  46. package/skills/whats-happening/SKILL.md +1 -1
  47. package/skills/whats-happening/references/reading-the-board.md +1 -1
  48. package/skills/whats-happening/references/why-is-it-stuck.md +1 -0
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // where-am-i.mjs — how far has setup got? SEVEN PARTS, EACH READ BY THE INSTRUMENT THAT OWNS IT, and
2
+ // where-am-i.mjs — how far has setup got? EIGHT PARTS, EACH READ BY THE INSTRUMENT THAT OWNS IT, and
3
+ // reported in the order a person can act on them (coding account first, then the integrations, the
4
+ // project, its repository, the person's own connected accounts, the repository's declaration, the host);
3
5
  // each finding labelled with the part it belongs to. The whole point of this script is that no part
4
6
  // answers for another: a project that is not ready is reported as a project finding with a tenant
5
7
  // owner's name on it, never as something the person at this keyboard can fix by running anything.
@@ -18,8 +20,10 @@ const NOTES = [
18
20
  "`replica status --probe --json` and `events status --probe --json` (optional local freshness),",
19
21
  "`me --json` and personal connection statuses (person), `contract --path …` for the account, the projects and the repositories,",
20
22
  "`contract --path codingAccounts` and `accounts --json` (coding accounts, and which one needs a new credential), and each",
21
- "project's hosts_current check with its fixedWhere (host).",
23
+ "project's hosts_current check with its fixedWhere (host), and each project's environment_declared check with its per-repository notes (repository declarations). The teams read is refreshed, so a mapping just saved reads back mapped.",
24
+ "Reads `capabilities --json` once: when the installed CLI can run a step for this person's role (`team check`, `team map`), the next step is that command; otherwise the page, with who can.",
22
25
  "Also runs `gh repo view <owner>/thoughts` for each registered repository's owner, as a note: it shows the repository exists, never that the GitHub App can reach it.",
26
+ "When the tenant serves a contract version this CLI refuses, runs `npm view @catalyst-cloud/cli version` once to say whether a newer CLI is published.",
23
27
  "Writes nothing and changes nothing. Runs before this machine is connected — that is one of the states it reports.",
24
28
  ];
25
29
 
@@ -42,17 +46,46 @@ let personalGithubIncomplete = false;
42
46
  let personalNext = null;
43
47
  let workspaceResolved = false;
44
48
  let repositoryRegistered = false;
49
+ // what the installed CLI can do, read once from `capabilities --json` (an older CLI has no
50
+ // such verb and reads as "nothing known"), and the person's role from `me`. Together they decide
51
+ // whether a next step is a command this person can run or a page for the person who can.
52
+ let capabilities = null;
53
+ let personRole = null;
54
+ const canManage = () => personRole === "admin" || personRole === "owner";
55
+ const capability = (verb) => (Array.isArray(capabilities) ? capabilities.find((c) => c && c.verb === verb) : undefined);
56
+ const verbAvailable = (verb) => { const c = capability(verb); return c?.availability === "available" && (c.needs === "member" || canManage()); };
57
+ const verbAdminOnly = (verb) => { const c = capability(verb); return c?.availability === "available" && c.needs === "admin" && !canManage(); };
45
58
  // `blocking` is false for a finding that is real and reportable but does not stop the next step —
46
59
  // an unmatched Linear identity is the one that matters: it must be said, and it must not become the
47
60
  // thing the person is told to go and do before they can map a project.
48
- const add = (part, instrument, verdict, lines, owner = null, where = null, blocking = true) =>
49
- parts.push({ part, instrument, verdict, lines, owner, where, blocking });
61
+ const add = (part, instrument, verdict, lines, owner = null, where = null, blocking = true) => {
62
+ const p = { part, instrument, verdict, lines, owner, where, blocking };
63
+ parts.push(p);
64
+ return p;
65
+ };
66
+
67
+ // A tenant contract outside this CLI's range makes every contract read fail the same way. When that
68
+ // happens, no part read from the contract can back a next step, and the only honest next step is the
69
+ // version itself. The CLI's own hint says to update, which is wrong when no newer CLI is published.
70
+ let contractMismatch = null;
71
+ const noteMismatch = (text) => {
72
+ const m =
73
+ (text ?? "").match(/serves contract version (\S+) but this bundle accepts (\S+)/) ??
74
+ (text ?? "").match(/contract: version (\S+) is outside this bundle's range (\S+)/);
75
+ if (m !== null) contractMismatch ??= { served: m[1], accepts: m[2] };
76
+ return m !== null;
77
+ };
78
+ const readContract = (args) => {
79
+ const r = runCli(args);
80
+ noteMismatch(r.stderr);
81
+ return r;
82
+ };
50
83
 
51
84
  // ── machine ───────────────────────────────────────────────────────────────────────────────────────
52
85
  const status = runCli(["status"]);
53
86
  if (!status.ran) {
54
87
  process.stderr.write(`${status.stderr}\n`);
55
- process.stderr.write(`the catalyst-skills CLI could not be started. Install it, then run this again.\n`);
88
+ process.stderr.write(`the catalyst CLI could not be started. Install it, then run this again.\n`);
56
89
  process.exit(2);
57
90
  }
58
91
  const statusLines = status.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -72,7 +105,7 @@ const machineLines = connected ? statusLines : [statusLines[0] ?? "status printe
72
105
  let machineVerdict = connected ? "ok" : "unfinished";
73
106
 
74
107
  // `ready` is ONE verdict over two parts; split it by check id before anything is reported. A check
75
- // whose id begins with "team:" belongs to a project and cannot be moved from this machine.
108
+ // whose id begins with "team:" belongs to a project and needs the member's admin or owner seat.
76
109
  let projectChecks = [];
77
110
  let machineFix = null;
78
111
  let localSync;
@@ -87,10 +120,13 @@ if (connected) {
87
120
  projectChecks = checks.filter((c) => typeof c.id === "string" && c.id.startsWith("team:"));
88
121
  const machineChecks = checks.filter((c) => !(typeof c.id === "string" && c.id.startsWith("team:")));
89
122
  const failed = machineChecks.filter((c) => !c.ok && !c.note);
90
- for (const c of machineChecks) machineLines.push(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}${!c.ok && !c.note && c.fix ? ` — fix: ${c.fix}` : ""}`);
123
+ // A refused contract version is printed without the CLI's "update" fix: whether an update exists
124
+ // is checked below, once, against what npm has published.
125
+ const refused = (c) => !c.ok && !c.note && noteMismatch(c.line);
126
+ for (const c of machineChecks) machineLines.push(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}${!c.ok && !c.note && c.fix && !refused(c) ? ` — fix: ${c.fix}` : ""}`);
91
127
  if (failed.length > 0) {
92
128
  machineVerdict = "unfinished";
93
- machineFix = failed.find((c) => typeof c.fix === "string")?.fix ?? null;
129
+ machineFix = failed.find((c) => typeof c.fix === "string" && !refused(c))?.fix ?? null;
94
130
  }
95
131
  }
96
132
  // Supplemental only: local caches are optional and do not change setup completion or --next.
@@ -102,31 +138,38 @@ if (connected) {
102
138
  localSync = {
103
139
  assessment: { verdict: "unknown", current: false, reason: "connect this machine before local freshness can be checked" },
104
140
  started: false,
105
- recovery: "catalyst-skills login",
141
+ recovery: "catalyst login",
106
142
  };
107
143
  }
108
144
  add(
109
145
  "machine",
110
- "catalyst-skills status, and the non-team checks of catalyst-skills ready",
146
+ "catalyst status, and the non-team checks of catalyst ready",
111
147
  machineVerdict,
112
148
  machineLines,
113
149
  "you, on this machine",
114
- connected ? null : CONNECT_LINE,
150
+ // The keyless login alone: a person who holds a personal key already knows the key form. A machine
151
+ // that holds the CLI is told its own command; only one with no CLI at all gets the npx line.
152
+ connected ? null : cliTarget().recorded ? "catalyst login" : CONNECT_LINE.split(" (or")[0],
115
153
  );
154
+ if (connected) {
155
+ const capDoc = tryJson(runCli(["capabilities", "--json"]).stdout);
156
+ capabilities = Array.isArray(capDoc?.capabilities) ? capDoc.capabilities : null;
157
+ }
116
158
 
117
159
  // ── person ────────────────────────────────────────────────────────────────────────────────────────
118
160
  if (!connected) {
119
- add("person", "catalyst-skills me", "unreadable", ["not readable until this machine is connected"], null, null);
161
+ add("person", "catalyst me", "unreadable", ["not readable until this machine is connected"], null, null);
120
162
  } else {
121
163
  const me = runCli(["me", "--json"]);
122
164
  const doc = tryJson(me.stdout);
123
165
  const user = doc && typeof doc.user === "object" && doc.user !== null ? doc.user : null;
124
166
  if (doc === null) {
125
- add("person", "catalyst-skills me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null);
167
+ add("person", "catalyst me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null).next =
168
+ "run this again; catalyst me could not be read, so no step after it can be named yet";
126
169
  } else if (user === null) {
127
170
  add(
128
171
  "person",
129
- "catalyst-skills me",
172
+ "catalyst me",
130
173
  "unfinished",
131
174
  ["this credential names no person — it is a host credential, so nothing an agent writes will carry a name"],
132
175
  "you: connect again with your own login",
@@ -134,6 +177,7 @@ if (!connected) {
134
177
  );
135
178
  } else {
136
179
  const matched = typeof user.linearUserId === "string" && user.linearUserId !== "";
180
+ personRole = typeof user.role === "string" ? user.role : null;
137
181
  const grantLines = [];
138
182
  for (const provider of ["linear", "github"]) {
139
183
  const read = runCli(["connections", "personal", provider, "status", "--json"]);
@@ -152,17 +196,17 @@ if (!connected) {
152
196
  personalGrantIncomplete = true;
153
197
  if (provider === "linear") personalLinearIncomplete = true;
154
198
  else personalGithubIncomplete = true;
155
- grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst-skills CLI or inspect its status output"}`);
199
+ grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst CLI or inspect its status output"}`);
156
200
  if (provider === "linear") personalNext ??= "re-check your personal linear connection";
157
201
  }
158
202
  }
159
203
  add(
160
204
  "person",
161
- "catalyst-skills me, and catalyst-skills connections personal <provider> status --json",
205
+ "catalyst me, and catalyst connections personal <provider> status --json",
162
206
  matched && !personalGrantIncomplete ? "ok" : "unfinished",
163
- [`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst-skills identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.", ...grantLines],
164
- personalGrantIncomplete ? "you" : matched ? null : "a tenant owner or admin",
165
- personalGrantIncomplete ? "catalyst-skills connections personal <provider> start or status" : matched ? null : link("/settings/account"),
207
+ [`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.", ...grantLines],
208
+ personalGrantIncomplete ? "you" : matched ? null : "a workspace owner or admin",
209
+ personalGrantIncomplete ? "catalyst connections personal <provider> start or status" : matched ? null : link("/settings/account"),
166
210
  false,
167
211
  );
168
212
  }
@@ -170,12 +214,13 @@ if (!connected) {
170
214
 
171
215
  // ── account ───────────────────────────────────────────────────────────────────────────────────────
172
216
  if (!connected) {
173
- add("account", "catalyst-skills contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
217
+ add("account", "catalyst contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
174
218
  } else {
175
- const acct = runCli(["contract", "--path", "account", "--json"]);
219
+ const acct = readContract(["contract", "--path", "account", "--json"]);
176
220
  const doc = tryJson(acct.stdout);
177
221
  if (doc === null) {
178
- add("account", "catalyst-skills contract --path account", "unreadable", ["the account block could not be read — try: catalyst-skills contract --refresh"], null, null);
222
+ add("account", "catalyst contract --path account", "unreadable", ["the account block could not be read — try: catalyst contract --refresh"], null, null).next =
223
+ "refresh the contract (catalyst contract --refresh), then run this again; the account could not be read, so no step after it can be named yet";
179
224
  } else {
180
225
  const workspace = typeof doc.linearWorkspaceSlug === "string" && doc.linearWorkspaceSlug !== "" ? doc.linearWorkspaceSlug : typeof doc.linearWorkspaceId === "string" && doc.linearWorkspaceId !== "" ? doc.linearWorkspaceId : null;
181
226
  workspaceResolved = workspace !== null;
@@ -187,7 +232,7 @@ if (!connected) {
187
232
  if (envDoc === null) {
188
233
  envLines.push(`environment: not readable (${(env.stderr || env.stdout).trim().split("\n")[0] ?? "no output"})`);
189
234
  } else if (envDoc.current === null || envDoc.current === undefined) {
190
- envLines.push("environment: nothing declared yet — declare it with `catalyst-skills environment propose --file <path> --approve`");
235
+ envLines.push("environment: nothing declared yet — declare it with `catalyst environment propose --file <path> --approve`");
191
236
  } else {
192
237
  envLines.push(`environment: revision ${envDoc.current.revision} (${envDoc.current.canonicalHash}), ${envDoc.isApproved ? "approved" : "NOT approved — a proposal nobody approved changes nothing"}`);
193
238
  envLines.push(envDoc.delivered ? `environment delivered to phases: revision ${envDoc.delivered.revision}` : "environment delivered to phases: nothing yet");
@@ -196,7 +241,7 @@ if (!connected) {
196
241
  }
197
242
  add(
198
243
  "account",
199
- "catalyst-skills contract --path account, and catalyst-skills environment read",
244
+ "catalyst contract --path account, and catalyst environment read",
200
245
  workspace === null ? "unfinished" : "ok",
201
246
  [
202
247
  `${doc.name ?? "(unnamed tenant)"} (${doc.slug ?? "?"})`,
@@ -204,7 +249,7 @@ if (!connected) {
204
249
  "the GitHub App install is NOT carried here — the connections page is the only place that shows it",
205
250
  ...envLines,
206
251
  ],
207
- "a tenant owner or admin",
252
+ "a workspace owner or admin",
208
253
  link("/settings/connections"),
209
254
  );
210
255
  }
@@ -212,16 +257,40 @@ if (!connected) {
212
257
 
213
258
  // The host part reads the same rows, so they are kept rather than read twice.
214
259
  let teamRows = null;
260
+ // The contract's readinessChecks metadata (severity and owner per check id), read once and shared by
261
+ // the projects, host and declarations parts. An older cloud that has none answers [].
262
+ let readinessMetaCache;
263
+ const readinessMeta = () => {
264
+ if (readinessMetaCache === undefined) {
265
+ const m = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
266
+ readinessMetaCache = Array.isArray(m) ? m : [];
267
+ }
268
+ return readinessMetaCache;
269
+ };
270
+ // Linear's own Git automation rules, by check id, as the Settings → Workflow → Git automation screen
271
+ // names them. A rule that moves a card is a conflict with the stage Catalyst manages, and no Catalyst
272
+ // key can change a Linear automation rule: the fix is in Linear, by a Linear admin.
273
+ const LINEAR_AUTOMATION_RULES = {
274
+ linear_automation_pr_open: "On PR open",
275
+ linear_automation_pr_review: "On PR review request or activity",
276
+ linear_automation_pr_ready: "On PR ready for merge",
277
+ linear_automation_pr_merge: "On PR merge",
278
+ };
279
+ let blockedProjects = [];
280
+
215
281
  // ── projects (a project is one Linear team) ───────────────────────────────────────────────────────
216
282
  if (!connected) {
217
- add("projects", "catalyst-skills contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
283
+ add("projects", "catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
218
284
  } else {
219
- const teams = runCli(["contract", "--path", "teams", "--json"]);
285
+ // The mapping write updates the cloud before the cached contract's dispatchGate projection.
286
+ // Revalidate this one read so a successful save does not appear unmapped on the next step.
287
+ const teams = readContract(["contract", "--refresh", "--path", "teams", "--json"]);
220
288
  const doc = tryJson(teams.stdout);
221
289
  const rows = Array.isArray(doc) ? doc : null;
222
290
  teamRows = rows;
223
291
  if (rows === null) {
224
- add("projects", "catalyst-skills contract --path teams", "unreadable", ["the project list could not be read — try: catalyst-skills contract --refresh"], null, null);
292
+ add("projects", "catalyst contract --path teams", "unreadable", ["the project list could not be read — try: catalyst contract --refresh"], null, null).next =
293
+ "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no step after it can be named yet";
225
294
  } else {
226
295
  const lines = [`${rows.length} mapped`];
227
296
  for (const t of rows) {
@@ -231,19 +300,42 @@ if (!connected) {
231
300
  lines.push(`${key}: ${readiness.status ?? "unknown"}${bad.length ? ` — ${bad.map((c) => `${c.id} ${c.state}`).join(", ")}` : ""}`);
232
301
  }
233
302
  for (const c of projectChecks) lines.push(`${c.ok ? "note" : "FAIL"} ${c.line}${c.who ? ` — who: ${c.who}` : ""}`);
234
- lines.push("⛔ MAPPED projects only. An empty list means nothing is mapped yet, NOT that there are no projects — the full list is on the page below.");
303
+ lines.push("MAPPED projects only. An empty list means nothing is mapped yet, NOT that there are no projects. Run catalyst team list to inspect the live list without checking readiness.");
235
304
  // A project is set up when its dispatch gate is open (its stages are mapped), or when its
236
305
  // readiness reads ready. Readiness alone kept `--next` on "map its stages" for a tenant whose
237
306
  // gates were open: readiness stays "unchecked" until someone presses Re-check, and a new team
238
307
  // stays "degraded" until a repository is attached, which is the step AFTER this one.
239
- const setUp = (t) => t.dispatchGate?.status === "open" || (t.readiness?.status ?? "unchecked") === "ready";
308
+ // ⛔ A team whose readiness reads `blocked` is NOT set up, whatever its gate says: a blocking
309
+ // check (a Linear automation that moves cards off Catalyst's stages, a missing OAuth scope) breaks
310
+ // every run in it. The step is that check's own fix, by its own owner, then a re-check.
311
+ const isBlocking = (c) => readinessMeta().length === 0 || readinessMeta().find((r) => r.id === c.id)?.severity === "blocking";
312
+ for (const t of rows.filter((r) => r.readiness?.status === "blocked")) {
313
+ const key = t.key ?? t.id ?? "(unkeyed)";
314
+ const fails = (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.state === "fail" && isBlocking(c));
315
+ const ids = fails.map((c) => c.id);
316
+ const rules = ids.filter((id) => id in LINEAR_AUTOMATION_RULES).map((id) => LINEAR_AUTOMATION_RULES[id]);
317
+ const others = ids.filter((id) => !(id in LINEAR_AUTOMATION_RULES));
318
+ const ownerRow = fails.map((c) => readinessMeta().find((r) => r.id === c.id)).find((r) => r && typeof r.fixedByLine === "string");
319
+ const owner = ownerRow ? ownerRow.fixedByLine : "a workspace owner or admin";
320
+ const how = [
321
+ rules.length > 0 ? `in Linear, open Settings → Teams → ${key} → Workflow → Git automation and set ${rules.join(", ")} to No action (no Catalyst key can change a Linear automation rule)` : null,
322
+ others.length > 0 ? `${others.join(", ")}: ${owner}` : null,
323
+ ].filter(Boolean).join("; ");
324
+ lines.push(`${key}: BLOCKED — ${ids.length > 0 ? ids.join(", ") : "a blocking check failed"}; ${how || `owner: ${owner}`}`);
325
+ blockedProjects.push({ key, action: `fix ${key}'s blocking check${ids.length === 1 ? "" : "s"} (${ids.join(", ") || "see the projects lines"}): ${how || owner}; then run catalyst team check ${key} (or press Re-check) and run this again`, owner });
326
+ }
327
+ const setUp = (t) => t.readiness?.status !== "blocked" && (t.dispatchGate?.status === "open" || (t.readiness?.status ?? "unchecked") === "ready");
240
328
  for (const t of rows) {
241
329
  if (t.dispatchGate?.status === "open" && (t.readiness?.status ?? "unchecked") === "unchecked") {
242
- lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: press Re-check on the page below to see the rest`);
330
+ lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: run catalyst team check ${t.key ?? t.id} to see the rest`);
243
331
  }
244
332
  }
245
333
  const ready = rows.length > 0 && rows.every(setUp);
246
- add("projects", "catalyst-skills contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, "a tenant owner or admin", link("/settings/linear-teams"));
334
+ const mapOwner = verbAvailable("team map") ? "you, with your own login" : "a workspace owner or admin";
335
+ const mapWhere = verbAvailable("team map") ? "catalyst team list" : link("/settings/linear-teams");
336
+ const projectOwner = blockedProjects.length > 0 ? blockedProjects[0].owner : mapOwner;
337
+ const projectWhere = blockedProjects.length > 0 ? null : mapWhere;
338
+ add("projects", "catalyst contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, projectOwner, projectWhere);
247
339
  }
248
340
  }
249
341
 
@@ -260,19 +352,20 @@ function thoughtsNote(owner) {
260
352
  return `note ${repo}: gh could not see it (${why}). It does not exist, or this GitHub login cannot see it. Create or confirm it (step 5a).`;
261
353
  }
262
354
  if (!connected) {
263
- add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
355
+ add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
264
356
  } else {
265
- const repos = runCli(["contract", "--path", "merge.repositories", "--json"]);
357
+ const repos = readContract(["contract", "--path", "merge.repositories", "--json"]);
266
358
  const doc = tryJson(repos.stdout);
267
359
  const rows = Array.isArray(doc) ? doc : null;
268
360
  if (rows === null) {
269
- add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst-skills contract --refresh"], null, null);
361
+ add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst contract --refresh"], null, null).next =
362
+ "refresh the contract (catalyst contract --refresh), then run this again; the repository list could not be read, so no step after it can be named yet";
270
363
  } else {
271
364
  const lines = [`${rows.length} registered`, ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`)];
272
365
  repositoryRegistered = rows.length > 0;
273
366
  lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
274
367
  for (const owner of [...new Set(rows.map((r) => r.owner).filter((o) => typeof o === "string" && o !== ""))]) lines.push(thoughtsNote(owner));
275
- add("repositories", "catalyst-skills contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a tenant owner or admin", link("/settings/repositories"));
368
+ add("repositories", "catalyst contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/repositories"));
276
369
  }
277
370
  }
278
371
 
@@ -282,17 +375,29 @@ if (!connected) {
282
375
  // The contract says the state, its sentence, who enrolls one and on which page. An older cloud's
283
376
  // contract has no `codingAccounts`, and only then is the owner assumed.
284
377
  let accountsNext = "enrol a coding account a phase can run on";
378
+ // A note --next prints beside the next step: an ended or cancelled account is not unfinished, but the
379
+ // person must hear that it is kept for reporting and never re-tokened, before anyone reaches for a setup token.
380
+ let retireNote = null;
285
381
  // The contract counts accounts by declared rotation, so one healthy account can hide another whose
286
382
  // credential is dead. When it says `enrolled`, each account is read as well. An account needs a new
287
383
  // credential when it is quarantined, expired or revoked, or when its last polls failed on the
288
- // credential itself. The poll fields are read only when the cloud sends them.
289
- const CREDENTIAL_ERROR_CODES = new Set(["no_access_token", "no_credential", "http_401", "http_403", "usage_unauthorized", "usage_forbidden"]);
384
+ // credential itself. The cloud's own verdict, `needsCredential`, wins when it is sent:
385
+ // it leaves out ended and revoked accounts and usage-endpoint refusals, which are not verdicts on the
386
+ // credential. Older clouds send only the poll fields, read with the same narrowed code list.
387
+ const CREDENTIAL_ERROR_CODES = new Set(["no_access_token", "no_credential"]);
290
388
  const CREDENTIAL_FAILURE_STREAK = 3;
291
389
  // A cancelled subscription or an ended account is not a credential problem: no token brings it
292
- // back. It is retired, never re-credentialed, and it does not make setup unfinished.
390
+ // back. It is kept for reporting, never re-credentialed, and it does not make setup unfinished.
293
391
  const isRetirable = (a) => a?.renewalStatus === "canceled" || a?.status === "ended" || a?.declaredState === "ended";
294
392
  const credentialProblem = (a) => {
295
393
  if (isRetirable(a)) return null;
394
+ if (typeof a?.needsCredential === "boolean") {
395
+ if (!a.needsCredential) return null;
396
+ if (a.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
397
+ if (a.status === "expired-or-revoked") return "expired or revoked";
398
+ return typeof a.lastPollErrorCode === "string" ? `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}` : "the cloud says it needs a new credential";
399
+ }
400
+ if (a?.status === "ended" || (a?.revokedAtMs ?? null) !== null) return null;
296
401
  if (a?.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
297
402
  if (a?.status === "expired-or-revoked") return "expired or revoked";
298
403
  if (CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) && typeof a?.pollFailureCount === "number" && a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK) {
@@ -307,24 +412,25 @@ const readAccountRows = () => {
307
412
  return { rows, error: rows === null ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output" : null };
308
413
  };
309
414
  if (!connected) {
310
- add("coding accounts", "codingAccounts in catalyst-skills contract", "unreadable", ["not readable until this machine is connected"], null, null);
415
+ add("coding accounts", "codingAccounts in catalyst contract", "unreadable", ["not readable until this machine is connected"], null, null);
311
416
  } else {
312
- const res = runCli(["contract", "--path", "codingAccounts", "--json"]);
417
+ const res = readContract(["contract", "--path", "codingAccounts", "--json"]);
313
418
  const ca = tryJson(res.stdout);
314
419
  const older = ca === null && /has nothing at/.test(res.stderr ?? "");
315
- const INSTRUMENT = "codingAccounts in catalyst-skills contract";
420
+ const INSTRUMENT = "codingAccounts in catalyst contract";
316
421
  if (ca !== null && typeof ca === "object" && typeof ca.state === "string") {
317
422
  const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
318
423
  const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
319
424
  const where = typeof ca.page === "string" ? link(ca.page) : null;
320
425
  if (ca.state === "enrolled" || ca.state === "needs_credential") {
321
- const DETAIL = `${INSTRUMENT}, and catalyst-skills accounts`;
426
+ const DETAIL = `${INSTRUMENT}, and catalyst accounts`;
322
427
  const head = [line, `${ca.activeCount ?? "?"} active`];
323
428
  const { rows, error } = readAccountRows();
324
429
  const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
325
430
  const retirable = rows === null ? [] : rows.filter(isRetirable);
326
431
  const who = (a) => [a.label, a.email].find((v) => typeof v === "string" && v !== "");
327
- const retireLine = retirable.length === 0 ? [] : [`${retirable.length} account(s) ended or cancelled (${retirable.map((a) => `${a.provider ?? "unknown provider"} ${a.accountSlot ?? "?"}`).join(", ")}): retire them on the AI accounts page. Do not replace their credential; no token revives a cancelled subscription.`];
432
+ const retireLine = retirable.length === 0 ? [] : [`${retirable.length} account(s) cancelled or ended (${retirable.map((a) => `${who(a) ?? a.displayName ?? a.accountSlot ?? "?"} (${a.provider ?? "unknown provider"})`).join(", ")}): kept for reporting, not used, and not counted here. Never replace their credential; reactivate one on the AI accounts page only if its subscription is live again.`];
433
+ retireNote = retireLine[0] ?? null;
328
434
  if (rows === null) {
329
435
  accountsNext = "read the coding accounts again; each account's credential could not be checked";
330
436
  add("coding accounts", DETAIL, "unreadable", [...head, `coding accounts could not be checked in detail (${error}). Whether each one still has a working credential is unknown. Do not enroll one on this reading.`], null, null);
@@ -360,31 +466,41 @@ if (!connected) {
360
466
  const { rows, error } = readAccountRows();
361
467
  const olderLine = "this cloud is older than the bundle: its contract does not say whether a coding account is enrolled, so the account list is read instead";
362
468
  if (rows === null) {
363
- add("coding accounts", "catalyst-skills accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
469
+ accountsNext = "read the coding accounts again; the account list could not be read this time";
470
+ add("coding accounts", "catalyst accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
364
471
  } else {
365
472
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
366
473
  const usable = rows.filter((a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true);
367
474
  const lines = [olderLine, `${rows.length} enrolled, ${usable.length} able to take work`];
368
475
  for (const a of rows) lines.push(`${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`);
369
476
  if (usable.length === 0) lines.push("no phase can start until one is enrolled and able to take work");
370
- add("coding accounts", "catalyst-skills accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a tenant owner or admin", link("/settings/coding-accounts"));
477
+ add("coding accounts", "catalyst accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/coding-accounts"));
371
478
  }
372
479
  } else {
373
480
  accountsNext = "read the coding accounts again; the contract could not be read";
374
- add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst-skills contract --refresh`], null, null);
481
+ add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst contract --refresh`], null, null);
375
482
  }
376
483
  }
377
484
 
485
+ // the Re-check step, as a command when this person can run it, else as the page with the
486
+ // person who can. `team check` is the CLI twin of the page's Re-check button (admin or owner).
487
+ const mappedKeys = () => (teamRows ?? []).map((t) => t.key ?? t.id).filter((k) => typeof k === "string" && k !== "");
488
+ const recheckStep = () => {
489
+ const cmds = mappedKeys().map((k) => `catalyst team check ${k}`);
490
+ if (cmds.length > 0 && verbAvailable("team check")) return { action: `run ${cmds.join(" and ")}, then run this again`, owner: "you, the assistant: run it now with the person's login, without asking", where: cmds[0] };
491
+ if (cmds.length > 0 && verbAdminOnly("team check")) return { action: `a workspace owner or admin runs ${cmds.join(" and ")} (or presses Re-check on the projects page); then run this again`, owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
492
+ return { action: "press Re-check on the projects page, then run this again", owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
493
+ };
494
+
378
495
  // ── host ──────────────────────────────────────────────────────────────────────────────────────────
379
496
  // Read off the contract, never assumed: each checked project carries a hosts_current check, and the
380
497
  // contract says who owns it. The check is account-wide, so any one project's reading is the answer.
381
498
  // A tenant that runs no host of its own reads `pass` here, and then nothing is asked of anyone.
382
499
  if (!connected) {
383
- add("host", "hosts_current in catalyst-skills contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
500
+ add("host", "hosts_current in catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
384
501
  } else {
385
502
  const checks = (teamRows ?? []).flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "hosts_current").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
386
- const meta = tryJson(runCli(["contract", "--path", "readinessChecks", "--json"]).stdout);
387
- const row = Array.isArray(meta) ? meta.find((r) => r.id === "hosts_current") : null;
503
+ const row = readinessMeta().find((r) => r.id === "hosts_current") ?? null;
388
504
  const hc = checks.find((c) => c.state === "fail") ?? checks.find((c) => c.state === "unknown") ?? checks[0] ?? null;
389
505
  // The contract's printed line for this owner is written for a host that is behind. When no host is
390
506
  // connected there is nothing behind, so the owner is named by its id instead of by that sentence.
@@ -399,12 +515,81 @@ if (!connected) {
399
515
  const how = fw === null ? null : link(fw.page);
400
516
  const howLines = fw?.command ? [`the owner runs: ${fw.command}`] : [];
401
517
  if (hc === null) {
402
- add("host", "hosts_current in catalyst-skills contract --path teams", "unreadable", [teamRows === null ? "the project list could not be read, so the host check cannot be either" : "no project has a readiness check yet, so whether a host is connected cannot be read. Press Re-check on the projects page."], "a tenant owner or admin", link("/settings/linear-teams"));
518
+ const step = recheckStep();
519
+ add("host", "hosts_current in catalyst contract --path teams", "unreadable", [teamRows === null ? "the project list could not be read, so the host check cannot be either" : "no project has a readiness check yet, so whether a host is connected cannot be read."], teamRows === null ? "a workspace owner or admin" : step.owner, teamRows === null ? link("/settings/linear-teams") : step.where).next =
520
+ teamRows === null
521
+ ? "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so the host check cannot be either"
522
+ : step.action;
403
523
  } else if (hc.state === "pass") {
404
- add("host", "hosts_current in catalyst-skills contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
524
+ add("host", "hosts_current in catalyst contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
405
525
  } else {
406
526
  const detail = hc.reason === "no_host_connected" ? "no Catalyst host is connected" : hc.reason === "hosts_behind" ? "a connected host runs an older mapping" : hc.reason === "hosts_unreported" ? "a host is connected but has not reported what it loaded" : `state ${hc.state}`;
407
- add("host", "hosts_current in catalyst-skills contract --path teams", "unfinished", [`hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`, ...howLines], owner, how);
527
+ add("host", "hosts_current in catalyst contract --path teams", "unfinished", [`hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`, ...howLines], owner, how);
528
+ }
529
+ }
530
+
531
+ // ── repository declarations ───────────────────────────────────────────────────────────────────────
532
+ // A repository's own settings live in its committed `.catalyst/catalyst.toml`. Whether the project's
533
+ // default repository has one in effect is the contract's `environment_declared` check; the project's
534
+ // other repositories appear under that check's `repos`. Names only: nothing here reads a value, and
535
+ // nothing here reads a file on this machine. A cloud that sends no such check has nothing to read.
536
+ const DECL_INSTRUMENT = "environment_declared in catalyst contract --path teams";
537
+ const DECL_REASONS = {
538
+ no_team_repo_default: { text: "no repository is the project's default yet: register one and make it the default", who: "a workspace owner or admin", page: "/settings/repositories" },
539
+ no_environment_declaration: { text: "no .catalyst/catalyst.toml on its default branch yet: write it with the person (names only, never a value), open a pull request, merge it", who: "the person, in the repository, with your help", page: null },
540
+ declaration_invalid: { text: "the committed .catalyst/catalyst.toml did not validate: fix the file (the ingest names the error) and merge the fix", who: "the person, in the repository, with your help", page: null },
541
+ declaration_read_failed: { text: "the committed .catalyst/catalyst.toml could not be read: fix the file (the ingest names the error) and merge the fix", who: "the person, in the repository, with your help", page: null },
542
+ declaration_awaiting_approval: { text: "the declaration is proposed and waits for approval: Settings → Repositories → the repository → Environment → Setup declaration → Approve this revision", who: "a workspace owner or admin", page: "/settings/repositories" },
543
+ };
544
+ const DECL_DO = "write .catalyst/catalyst.toml with references/declaring-a-repository.md, then open a pull request";
545
+ let declNext = "commit .catalyst/catalyst.toml to the repository and have an owner or admin approve it";
546
+ if (!connected) {
547
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["not readable until this machine is connected"], null, null);
548
+ } else if (teamRows === null) {
549
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["the project list could not be read, so no repository declaration can be either"], null, null).next =
550
+ "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no repository declaration can be either";
551
+ } else if (teamRows.length === 0) {
552
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project is mapped yet, so there is no repository whose declaration could be read"], null, null).next =
553
+ "map a project first; a repository declaration is read per project";
554
+ } else if (teamRows.every((t) => !Array.isArray(t.readiness?.checks) || t.readiness.checks.length === 0)) {
555
+ // Mapped, never checked: the contract carries no checks at all until someone presses Re-check, and
556
+ // that silence is not "nothing to declare".
557
+ const step = recheckStep();
558
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project has a readiness check yet, so whether its repository is declared cannot be read."], step.owner, step.where).next = step.action;
559
+ } else {
560
+ const found = teamRows.flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "environment_declared").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
561
+ const lines = [];
562
+ let verdict = "ok";
563
+ let owner = null;
564
+ let where = null;
565
+ let unknown = false;
566
+ const describe = (reason) => DECL_REASONS[reason] ?? { text: `environment_declared ${reason ?? "failed"} (a reason this bundle does not know; read it on the page)`, who: "a workspace owner or admin", page: "/settings/repositories" };
567
+ const flag = (team, repo, reason) => {
568
+ const r = describe(reason);
569
+ lines.push(`${team}${repo ? `, ${repo}` : ""}: ${r.text}`);
570
+ if (verdict === "ok") declNext = `${repo ? `${repo}: ` : ""}${r.text}`;
571
+ verdict = "unfinished";
572
+ owner ??= r.who;
573
+ where ??= r.page === null ? DECL_DO : link(r.page);
574
+ };
575
+ if (found.length === 0) {
576
+ lines.push("this cloud reports no repository declaration check for the mapped projects, so there is nothing to read here; the repository's Environment page is where one would show");
577
+ }
578
+ for (const c of found) {
579
+ if (c.state === "pass") lines.push(`${c.team}: a declaration is in effect for the project's default repository`);
580
+ else if (c.state === "fail") flag(c.team, null, c.reason);
581
+ else {
582
+ unknown = true;
583
+ lines.push(`${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; a re-check is needed`);
584
+ }
585
+ for (const note of Array.isArray(c.repos) ? c.repos : []) if (typeof note?.repo === "string" && note.reason) flag(c.team, note.repo, note.reason);
586
+ }
587
+ if (unknown && verdict === "ok") {
588
+ const step = recheckStep();
589
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", lines, step.owner, step.where).next = step.action;
590
+ } else {
591
+ if (verdict !== "ok") lines.push("Names only: a value never passes through this script or the file. The person enters values on the repository's Environment page.");
592
+ add("repository declarations", DECL_INSTRUMENT, verdict, lines, verdict === "ok" ? null : owner, verdict === "ok" ? null : where);
408
593
  }
409
594
  }
410
595
 
@@ -424,12 +609,26 @@ const NEXT = {
424
609
  : personalGithubIncomplete
425
610
  ? "install the tenant GitHub App and register its repository before connecting your personal GitHub account"
426
611
  : "get this person's seat and Linear identity sorted",
427
- account: "connect Linear, and install the GitHub App",
428
- projects: "pick ONE project and map its stages (or adopt the Catalyst workflow)",
429
- repositories: "register the repository, attaching it to the project you mapped",
612
+ account: "connect the Linear integration on the Connections page (the GitHub App comes later, with the repository)",
613
+ projects: blockedProjects.length > 0
614
+ ? blockedProjects.map((b) => b.action).join("; and ")
615
+ : verbAvailable("team map")
616
+ ? "pick ONE project: run catalyst team list, then catalyst team map <KEY> (or team adopt <KEY>) and approve its preview"
617
+ : verbAdminOnly("team map")
618
+ ? "a workspace owner or admin maps ONE project: catalyst team map <KEY>, or Map my stages on the projects page"
619
+ : "pick ONE project and map its stages (or adopt the Catalyst workflow)",
620
+ repositories: "install the GitHub App on the Connections page, granting it the repository you want worked; registering that repository on the Repositories page, attached to the project, is the step right after",
430
621
  "coding accounts": accountsNext,
622
+ "repository declarations": declNext,
431
623
  host: "connect a Catalyst host",
432
624
  };
625
+ // The parts are read in the order their data allows and reported in the order a person can act on
626
+ // them: the coding account first (nothing runs without one, and it needs no other step), then the
627
+ // workspace's Linear integration, the project, the GitHub App with its repository, the person's own
628
+ // connected accounts (personal GitHub after the repository proves the App), the repository's
629
+ // declaration, and last the host. `--next` is the first unfinished part in this order.
630
+ const ORDER = ["machine", "coding accounts", "account", "projects", "repositories", "person", "repository declarations", "host"];
631
+ parts.sort((a, b) => ORDER.indexOf(a.part) - ORDER.indexOf(b.part));
433
632
  // A personal Linear grant cannot start until the tenant's Linear workspace exists. Once that account
434
633
  // connection is present, a missing personal grant becomes the next member step before project setup.
435
634
  const personPart = parts.find((p) => p.part === "person");
@@ -441,16 +640,50 @@ if (personPart && personalGrantIncomplete && workspaceResolved) {
441
640
  }
442
641
  const blocked = parts.filter((p) => p.verdict !== "ok" && p.blocking);
443
642
  const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
444
- const next =
643
+ // ⛔ AN UNREADABLE PART NEVER BORROWS ITS "UNFINISHED" ACTION. NEXT holds what to do when a part was
644
+ // read and is not done. A part that could not be read has no such basis: its step is reading it
645
+ // again, and the parts after it depend on it, so none of theirs is named either. Coding accounts set
646
+ // their own unreadable step in `accountsNext`.
647
+ const actionFor = (p) =>
648
+ p.verdict === "unreadable" && p.part !== "coding accounts"
649
+ ? (p.next ?? `run this again; the ${p.part} part could not be read, so no step after it can be named yet`)
650
+ : NEXT[p.part];
651
+ let next =
445
652
  stuck === null
446
653
  ? null
447
- : { part: stuck.part, action: NEXT[stuck.part], owner: stuck.owner, where: stuck.where, blocking: stuck.blocking };
654
+ : { part: stuck.part, action: actionFor(stuck), owner: stuck.owner, where: stuck.where, blocking: stuck.blocking };
655
+
656
+ // A refused contract version outranks every step above: each of them was read from, or waits on, a
657
+ // contract this CLI would not accept. Whether an update exists is asked of npm, once, and only here.
658
+ function versionAdvice() {
659
+ const semver = (t) => (t ?? "").match(/\b(\d+)\.(\d+)\.(\d+)\b/);
660
+ const installed = semver(field("Bundle"));
661
+ const npm = spawnSync("npm", ["view", "@catalyst-cloud/cli", "version"], { encoding: "utf8", timeout: 10_000, shell: process.platform === "win32" });
662
+ const latest = npm.error || npm.status !== 0 ? null : semver(npm.stdout);
663
+ // The installer is the update path, not `catalyst install`. Its command is on the app's
664
+ // setup page; this names the script it fetches rather than composing the request here.
665
+ const reinstall = `re-run the install command from the app's setup page (it installs from ${link("/install.sh")})`;
666
+ if (installed === null || latest === null) {
667
+ return { text: `Whether a newer Catalyst CLI is published could not be checked. You can ${reinstall}; if this still appears afterwards, tell the Catalyst team.`, owner: null };
668
+ }
669
+ const order = [1, 2, 3].map((i) => Math.sign(Number(installed[i]) - Number(latest[i]))).find((d) => d !== 0) ?? 0;
670
+ return order >= 0
671
+ ? { text: "A newer Catalyst CLI isn't published yet. Tell the Catalyst team; nothing on this machine needs to change.", owner: "the Catalyst team" }
672
+ : { text: `Update the CLI: ${reinstall}.`, owner: "you, on this machine" };
673
+ }
674
+ if (contractMismatch !== null && stuck !== null) {
675
+ const advice = versionAdvice();
676
+ const line = `the tenant serves contract ${contractMismatch.served} and this CLI accepts ${contractMismatch.accepts}. ${advice.text}`;
677
+ stuck.lines.push(line);
678
+ next = { part: stuck.part, action: line, owner: advice.owner, where: null, blocking: true };
679
+ }
448
680
  const finished = parts.every((p) => p.verdict === "ok");
449
681
 
450
682
  if (flags.json) {
451
- console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished }));
683
+ console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished, notes: retireNote === null ? [] : [retireNote] }));
452
684
  } else if (flags.next) {
453
- if (next === null) console.log("nothing left: every part is finished, a coding account is enrolled and the host check passes. Move one card into the project's dispatch stage.");
685
+ process.on("exit", () => { if (retireNote !== null) console.log(`note: ${retireNote}`); });
686
+ if (next === null) console.log("nothing left: every part is finished, a coding account is enrolled and the host check passes. Move one card into the project's start stage (usually Todo).");
454
687
  else console.log(`${next.part}: ${next.action}${next.blocking ? "" : " (does not block the steps below)"}${next.owner ? ` — who: ${next.owner}` : ""}${next.where ? ` — ${next.where.startsWith("http") ? "where" : "do"}: ${next.where}` : ""}`);
455
688
  } else {
456
689
  for (const p of parts) {
@@ -463,6 +696,6 @@ if (flags.json) {
463
696
  if (p.verdict !== "ok" && p.where) console.log(` ${p.where.startsWith("http") ? "where" : "do"}: ${p.where}`);
464
697
  console.log("");
465
698
  }
466
- console.log(next === null ? "next: nothing left — move one card into the project's dispatch stage and watch." : `next: ${next.part} — ${next.action}${next.owner ? ` (${next.owner})` : ""}${next.where ? ` — ${next.where}` : ""}`);
699
+ console.log(next === null ? "next: nothing left — move one card into the project's start stage (usually Todo) and watch." : `next: ${next.part} — ${next.action}${next.owner ? ` (${next.owner})` : ""}${next.where ? ` — ${next.where}` : ""}`);
467
700
  }
468
701
  process.exit(finished ? 0 : 1);