@catalyst-cloud/cli 0.9.2 → 0.9.4

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.
@@ -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,9 @@ 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).",
22
24
  "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.",
25
+ "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
26
  "Writes nothing and changes nothing. Runs before this machine is connected — that is one of the states it reports.",
24
27
  ];
25
28
 
@@ -45,8 +48,28 @@ let repositoryRegistered = false;
45
48
  // `blocking` is false for a finding that is real and reportable but does not stop the next step —
46
49
  // an unmatched Linear identity is the one that matters: it must be said, and it must not become the
47
50
  // 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 });
51
+ const add = (part, instrument, verdict, lines, owner = null, where = null, blocking = true) => {
52
+ const p = { part, instrument, verdict, lines, owner, where, blocking };
53
+ parts.push(p);
54
+ return p;
55
+ };
56
+
57
+ // A tenant contract outside this CLI's range makes every contract read fail the same way. When that
58
+ // happens, no part read from the contract can back a next step, and the only honest next step is the
59
+ // version itself. The CLI's own hint says to update, which is wrong when no newer CLI is published.
60
+ let contractMismatch = null;
61
+ const noteMismatch = (text) => {
62
+ const m =
63
+ (text ?? "").match(/serves contract version (\S+) but this bundle accepts (\S+)/) ??
64
+ (text ?? "").match(/contract: version (\S+) is outside this bundle's range (\S+)/);
65
+ if (m !== null) contractMismatch ??= { served: m[1], accepts: m[2] };
66
+ return m !== null;
67
+ };
68
+ const readContract = (args) => {
69
+ const r = runCli(args);
70
+ noteMismatch(r.stderr);
71
+ return r;
72
+ };
50
73
 
51
74
  // ── machine ───────────────────────────────────────────────────────────────────────────────────────
52
75
  const status = runCli(["status"]);
@@ -87,10 +110,13 @@ if (connected) {
87
110
  projectChecks = checks.filter((c) => typeof c.id === "string" && c.id.startsWith("team:"));
88
111
  const machineChecks = checks.filter((c) => !(typeof c.id === "string" && c.id.startsWith("team:")));
89
112
  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}` : ""}`);
113
+ // A refused contract version is printed without the CLI's "update" fix: whether an update exists
114
+ // is checked below, once, against what npm has published.
115
+ const refused = (c) => !c.ok && !c.note && noteMismatch(c.line);
116
+ 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
117
  if (failed.length > 0) {
92
118
  machineVerdict = "unfinished";
93
- machineFix = failed.find((c) => typeof c.fix === "string")?.fix ?? null;
119
+ machineFix = failed.find((c) => typeof c.fix === "string" && !refused(c))?.fix ?? null;
94
120
  }
95
121
  }
96
122
  // Supplemental only: local caches are optional and do not change setup completion or --next.
@@ -111,7 +137,8 @@ add(
111
137
  machineVerdict,
112
138
  machineLines,
113
139
  "you, on this machine",
114
- connected ? null : CONNECT_LINE,
140
+ // The keyless login alone: a person who holds a personal key already knows the key form.
141
+ connected ? null : CONNECT_LINE.split(" (or")[0],
115
142
  );
116
143
 
117
144
  // ── person ────────────────────────────────────────────────────────────────────────────────────────
@@ -122,7 +149,8 @@ if (!connected) {
122
149
  const doc = tryJson(me.stdout);
123
150
  const user = doc && typeof doc.user === "object" && doc.user !== null ? doc.user : null;
124
151
  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);
152
+ add("person", "catalyst-skills me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null).next =
153
+ "run this again; catalyst-skills me could not be read, so no step after it can be named yet";
126
154
  } else if (user === null) {
127
155
  add(
128
156
  "person",
@@ -161,7 +189,7 @@ if (!connected) {
161
189
  "catalyst-skills me, and catalyst-skills connections personal <provider> status --json",
162
190
  matched && !personalGrantIncomplete ? "ok" : "unfinished",
163
191
  [`${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",
192
+ personalGrantIncomplete ? "you" : matched ? null : "a workspace owner or admin",
165
193
  personalGrantIncomplete ? "catalyst-skills connections personal <provider> start or status" : matched ? null : link("/settings/account"),
166
194
  false,
167
195
  );
@@ -172,10 +200,11 @@ if (!connected) {
172
200
  if (!connected) {
173
201
  add("account", "catalyst-skills contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
174
202
  } else {
175
- const acct = runCli(["contract", "--path", "account", "--json"]);
203
+ const acct = readContract(["contract", "--path", "account", "--json"]);
176
204
  const doc = tryJson(acct.stdout);
177
205
  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);
206
+ add("account", "catalyst-skills contract --path account", "unreadable", ["the account block could not be read — try: catalyst-skills contract --refresh"], null, null).next =
207
+ "refresh the contract (catalyst-skills contract --refresh), then run this again; the account could not be read, so no step after it can be named yet";
179
208
  } else {
180
209
  const workspace = typeof doc.linearWorkspaceSlug === "string" && doc.linearWorkspaceSlug !== "" ? doc.linearWorkspaceSlug : typeof doc.linearWorkspaceId === "string" && doc.linearWorkspaceId !== "" ? doc.linearWorkspaceId : null;
181
210
  workspaceResolved = workspace !== null;
@@ -204,7 +233,7 @@ if (!connected) {
204
233
  "the GitHub App install is NOT carried here — the connections page is the only place that shows it",
205
234
  ...envLines,
206
235
  ],
207
- "a tenant owner or admin",
236
+ "a workspace owner or admin",
208
237
  link("/settings/connections"),
209
238
  );
210
239
  }
@@ -216,12 +245,13 @@ let teamRows = null;
216
245
  if (!connected) {
217
246
  add("projects", "catalyst-skills contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
218
247
  } else {
219
- const teams = runCli(["contract", "--path", "teams", "--json"]);
248
+ const teams = readContract(["contract", "--path", "teams", "--json"]);
220
249
  const doc = tryJson(teams.stdout);
221
250
  const rows = Array.isArray(doc) ? doc : null;
222
251
  teamRows = rows;
223
252
  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);
253
+ add("projects", "catalyst-skills contract --path teams", "unreadable", ["the project list could not be read — try: catalyst-skills contract --refresh"], null, null).next =
254
+ "refresh the contract (catalyst-skills contract --refresh), then run this again; the project list could not be read, so no step after it can be named yet";
225
255
  } else {
226
256
  const lines = [`${rows.length} mapped`];
227
257
  for (const t of rows) {
@@ -243,7 +273,7 @@ if (!connected) {
243
273
  }
244
274
  }
245
275
  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"));
276
+ add("projects", "catalyst-skills contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/linear-teams"));
247
277
  }
248
278
  }
249
279
 
@@ -262,17 +292,18 @@ function thoughtsNote(owner) {
262
292
  if (!connected) {
263
293
  add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
264
294
  } else {
265
- const repos = runCli(["contract", "--path", "merge.repositories", "--json"]);
295
+ const repos = readContract(["contract", "--path", "merge.repositories", "--json"]);
266
296
  const doc = tryJson(repos.stdout);
267
297
  const rows = Array.isArray(doc) ? doc : null;
268
298
  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);
299
+ add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst-skills contract --refresh"], null, null).next =
300
+ "refresh the contract (catalyst-skills contract --refresh), then run this again; the repository list could not be read, so no step after it can be named yet";
270
301
  } else {
271
302
  const lines = [`${rows.length} registered`, ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`)];
272
303
  repositoryRegistered = rows.length > 0;
273
304
  lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
274
305
  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"));
306
+ add("repositories", "catalyst-skills contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/repositories"));
276
307
  }
277
308
  }
278
309
 
@@ -282,13 +313,29 @@ if (!connected) {
282
313
  // The contract says the state, its sentence, who enrolls one and on which page. An older cloud's
283
314
  // contract has no `codingAccounts`, and only then is the owner assumed.
284
315
  let accountsNext = "enrol a coding account a phase can run on";
316
+ // A note --next prints beside the next step: an ended or cancelled account is not unfinished, but the
317
+ // person must hear that it is kept for reporting and never re-tokened, before anyone reaches for a setup token.
318
+ let retireNote = null;
285
319
  // The contract counts accounts by declared rotation, so one healthy account can hide another whose
286
320
  // credential is dead. When it says `enrolled`, each account is read as well. An account needs a new
287
321
  // 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"]);
322
+ // credential itself. The cloud's own verdict, `needsCredential`, wins when it is sent:
323
+ // it leaves out ended and revoked accounts and usage-endpoint refusals, which are not verdicts on the
324
+ // credential. Older clouds send only the poll fields, read with the same narrowed code list.
325
+ const CREDENTIAL_ERROR_CODES = new Set(["no_access_token", "no_credential"]);
290
326
  const CREDENTIAL_FAILURE_STREAK = 3;
327
+ // A cancelled subscription or an ended account is not a credential problem: no token brings it
328
+ // back. It is kept for reporting, never re-credentialed, and it does not make setup unfinished.
329
+ const isRetirable = (a) => a?.renewalStatus === "canceled" || a?.status === "ended" || a?.declaredState === "ended";
291
330
  const credentialProblem = (a) => {
331
+ if (isRetirable(a)) return null;
332
+ if (typeof a?.needsCredential === "boolean") {
333
+ if (!a.needsCredential) return null;
334
+ if (a.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
335
+ if (a.status === "expired-or-revoked") return "expired or revoked";
336
+ return typeof a.lastPollErrorCode === "string" ? `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}` : "the cloud says it needs a new credential";
337
+ }
338
+ if (a?.status === "ended" || (a?.revokedAtMs ?? null) !== null) return null;
292
339
  if (a?.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
293
340
  if (a?.status === "expired-or-revoked") return "expired or revoked";
294
341
  if (CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) && typeof a?.pollFailureCount === "number" && a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK) {
@@ -305,7 +352,7 @@ const readAccountRows = () => {
305
352
  if (!connected) {
306
353
  add("coding accounts", "codingAccounts in catalyst-skills contract", "unreadable", ["not readable until this machine is connected"], null, null);
307
354
  } else {
308
- const res = runCli(["contract", "--path", "codingAccounts", "--json"]);
355
+ const res = readContract(["contract", "--path", "codingAccounts", "--json"]);
309
356
  const ca = tryJson(res.stdout);
310
357
  const older = ca === null && /has nothing at/.test(res.stderr ?? "");
311
358
  const INSTRUMENT = "codingAccounts in catalyst-skills contract";
@@ -318,18 +365,22 @@ if (!connected) {
318
365
  const head = [line, `${ca.activeCount ?? "?"} active`];
319
366
  const { rows, error } = readAccountRows();
320
367
  const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
368
+ const retirable = rows === null ? [] : rows.filter(isRetirable);
369
+ const who = (a) => [a.label, a.email].find((v) => typeof v === "string" && v !== "");
370
+ 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.`];
371
+ retireNote = retireLine[0] ?? null;
321
372
  if (rows === null) {
322
373
  accountsNext = "read the coding accounts again; each account's credential could not be checked";
323
374
  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);
324
375
  } else if (dead.length > 0) {
325
- const name = (a) => `${a.provider ?? "unknown provider"} account ${a.accountSlot ?? "?"}`;
376
+ const name = (a) => `${a.provider ?? "unknown provider"} account ${a.accountSlot ?? "?"}${who(a) ? ` (${who(a)})` : ""}`;
326
377
  const more = dead.length > 1 ? ` (and ${dead.length - 1} more)` : "";
327
378
  accountsNext = `${name(dead[0])} needs a new credential${more}. Replace it on the AI accounts page (Settings → AI accounts → the account → Replace credential). Do not enroll another account.`;
328
379
  add(
329
380
  "coding accounts",
330
381
  DETAIL,
331
382
  "unfinished",
332
- [...head, ...dead.map((a) => `${name(a)} needs a new credential: ${credentialProblem(a)}`), "Replace its credential on its own page. Do not enroll another account. The steps are in references/replacing-a-credential.md."],
383
+ [...head, ...dead.map((a) => `${name(a)} needs a new credential: ${credentialProblem(a)}`), "Replace its credential on its own page, with a credential minted from THAT account: check the login you mint from matches the account named here first. Do not enroll another account. The steps are in references/replacing-a-credential.md.", ...retireLine],
333
384
  owner,
334
385
  where,
335
386
  );
@@ -337,7 +388,7 @@ if (!connected) {
337
388
  accountsNext = "replace the credential of the coding account the page marks; do not enroll another account";
338
389
  add("coding accounts", DETAIL, "unfinished", [...head, "The contract says an account needs a new credential, and the account list does not say which. Open the page and look for it."], owner, where);
339
390
  } else {
340
- add("coding accounts", DETAIL, "ok", [...head, `${rows.length} checked, none needs a new credential`]);
391
+ add("coding accounts", DETAIL, "ok", [...head, `${rows.length} checked, none needs a new credential`, ...retireLine]);
341
392
  }
342
393
  } else if (ca.state === "inactive") {
343
394
  accountsNext = "reactivate a coding account that is out of rotation; do not enroll another one";
@@ -353,6 +404,7 @@ if (!connected) {
353
404
  const { rows, error } = readAccountRows();
354
405
  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";
355
406
  if (rows === null) {
407
+ accountsNext = "read the coding accounts again; the account list could not be read this time";
356
408
  add("coding accounts", "catalyst-skills accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
357
409
  } else {
358
410
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
@@ -360,7 +412,7 @@ if (!connected) {
360
412
  const lines = [olderLine, `${rows.length} enrolled, ${usable.length} able to take work`];
361
413
  for (const a of rows) lines.push(`${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`);
362
414
  if (usable.length === 0) lines.push("no phase can start until one is enrolled and able to take work");
363
- add("coding accounts", "catalyst-skills accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a tenant owner or admin", link("/settings/coding-accounts"));
415
+ add("coding accounts", "catalyst-skills accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/coding-accounts"));
364
416
  }
365
417
  } else {
366
418
  accountsNext = "read the coding accounts again; the contract could not be read";
@@ -376,7 +428,7 @@ if (!connected) {
376
428
  add("host", "hosts_current in catalyst-skills contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
377
429
  } else {
378
430
  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)" })));
379
- const meta = tryJson(runCli(["contract", "--path", "readinessChecks", "--json"]).stdout);
431
+ const meta = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
380
432
  const row = Array.isArray(meta) ? meta.find((r) => r.id === "hosts_current") : null;
381
433
  const hc = checks.find((c) => c.state === "fail") ?? checks.find((c) => c.state === "unknown") ?? checks[0] ?? null;
382
434
  // The contract's printed line for this owner is written for a host that is behind. When no host is
@@ -392,7 +444,10 @@ if (!connected) {
392
444
  const how = fw === null ? null : link(fw.page);
393
445
  const howLines = fw?.command ? [`the owner runs: ${fw.command}`] : [];
394
446
  if (hc === null) {
395
- 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"));
447
+ 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 workspace owner or admin", link("/settings/linear-teams")).next =
448
+ teamRows === null
449
+ ? "refresh the contract (catalyst-skills contract --refresh), then run this again; the project list could not be read, so the host check cannot be either"
450
+ : "press Re-check on the projects page, then run this again";
396
451
  } else if (hc.state === "pass") {
397
452
  add("host", "hosts_current in catalyst-skills contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
398
453
  } else {
@@ -401,6 +456,70 @@ if (!connected) {
401
456
  }
402
457
  }
403
458
 
459
+ // ── repository declarations ───────────────────────────────────────────────────────────────────────
460
+ // A repository's own settings live in its committed `.catalyst/catalyst.toml`. Whether the project's
461
+ // default repository has one in effect is the contract's `environment_declared` check; the project's
462
+ // other repositories appear under that check's `repos`. Names only: nothing here reads a value, and
463
+ // nothing here reads a file on this machine. A cloud that sends no such check has nothing to read.
464
+ const DECL_INSTRUMENT = "environment_declared in catalyst-skills contract --path teams";
465
+ const DECL_REASONS = {
466
+ 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" },
467
+ 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 },
468
+ 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 },
469
+ 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 },
470
+ 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" },
471
+ };
472
+ const DECL_DO = "write .catalyst/catalyst.toml with references/declaring-a-repository.md, then open a pull request";
473
+ let declNext = "commit .catalyst/catalyst.toml to the repository and have an owner or admin approve it";
474
+ if (!connected) {
475
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["not readable until this machine is connected"], null, null);
476
+ } else if (teamRows === null) {
477
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["the project list could not be read, so no repository declaration can be either"], null, null).next =
478
+ "refresh the contract (catalyst-skills contract --refresh), then run this again; the project list could not be read, so no repository declaration can be either";
479
+ } else if (teamRows.length === 0) {
480
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project is mapped yet, so there is no repository whose declaration could be read"], null, null).next =
481
+ "map a project first; a repository declaration is read per project";
482
+ } else if (teamRows.every((t) => !Array.isArray(t.readiness?.checks) || t.readiness.checks.length === 0)) {
483
+ // Mapped, never checked: the contract carries no checks at all until someone presses Re-check, and
484
+ // that silence is not "nothing to declare".
485
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project has a readiness check yet, so whether its repository is declared cannot be read. Press Re-check on the projects page."], "a workspace owner or admin", link("/settings/linear-teams")).next =
486
+ "press Re-check on the projects page, then run this again";
487
+ } else {
488
+ 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)" })));
489
+ const lines = [];
490
+ let verdict = "ok";
491
+ let owner = null;
492
+ let where = null;
493
+ let unknown = false;
494
+ 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" };
495
+ const flag = (team, repo, reason) => {
496
+ const r = describe(reason);
497
+ lines.push(`${team}${repo ? `, ${repo}` : ""}: ${r.text}`);
498
+ if (verdict === "ok") declNext = `${repo ? `${repo}: ` : ""}${r.text}`;
499
+ verdict = "unfinished";
500
+ owner ??= r.who;
501
+ where ??= r.page === null ? DECL_DO : link(r.page);
502
+ };
503
+ if (found.length === 0) {
504
+ 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");
505
+ }
506
+ for (const c of found) {
507
+ if (c.state === "pass") lines.push(`${c.team}: a declaration is in effect for the project's default repository`);
508
+ else if (c.state === "fail") flag(c.team, null, c.reason);
509
+ else {
510
+ unknown = true;
511
+ lines.push(`${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; press Re-check on the projects page`);
512
+ }
513
+ for (const note of Array.isArray(c.repos) ? c.repos : []) if (typeof note?.repo === "string" && note.reason) flag(c.team, note.repo, note.reason);
514
+ }
515
+ if (unknown && verdict === "ok") {
516
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", lines, "a workspace owner or admin", link("/settings/linear-teams")).next = "press Re-check on the projects page, then run this again";
517
+ } else {
518
+ 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.");
519
+ add("repository declarations", DECL_INSTRUMENT, verdict, lines, verdict === "ok" ? null : owner, verdict === "ok" ? null : where);
520
+ }
521
+ }
522
+
404
523
  // ── the single next step ──────────────────────────────────────────────────────────────────────────
405
524
  // The machine's next action is not one sentence: an unconnected machine needs the login, and a
406
525
  // connected one needs whatever check failed — and `ready` already printed that check's own fix, so
@@ -417,12 +536,20 @@ const NEXT = {
417
536
  : personalGithubIncomplete
418
537
  ? "install the tenant GitHub App and register its repository before connecting your personal GitHub account"
419
538
  : "get this person's seat and Linear identity sorted",
420
- account: "connect Linear, and install the GitHub App",
539
+ account: "connect the Linear integration on the Connections page (the GitHub App comes later, with the repository)",
421
540
  projects: "pick ONE project and map its stages (or adopt the Catalyst workflow)",
422
- repositories: "register the repository, attaching it to the project you mapped",
541
+ 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",
423
542
  "coding accounts": accountsNext,
543
+ "repository declarations": declNext,
424
544
  host: "connect a Catalyst host",
425
545
  };
546
+ // The parts are read in the order their data allows and reported in the order a person can act on
547
+ // them: the coding account first (nothing runs without one, and it needs no other step), then the
548
+ // workspace's Linear integration, the project, the GitHub App with its repository, the person's own
549
+ // connected accounts (personal GitHub after the repository proves the App), the repository's
550
+ // declaration, and last the host. `--next` is the first unfinished part in this order.
551
+ const ORDER = ["machine", "coding accounts", "account", "projects", "repositories", "person", "repository declarations", "host"];
552
+ parts.sort((a, b) => ORDER.indexOf(a.part) - ORDER.indexOf(b.part));
426
553
  // A personal Linear grant cannot start until the tenant's Linear workspace exists. Once that account
427
554
  // connection is present, a missing personal grant becomes the next member step before project setup.
428
555
  const personPart = parts.find((p) => p.part === "person");
@@ -434,16 +561,50 @@ if (personPart && personalGrantIncomplete && workspaceResolved) {
434
561
  }
435
562
  const blocked = parts.filter((p) => p.verdict !== "ok" && p.blocking);
436
563
  const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
437
- const next =
564
+ // ⛔ AN UNREADABLE PART NEVER BORROWS ITS "UNFINISHED" ACTION. NEXT holds what to do when a part was
565
+ // read and is not done. A part that could not be read has no such basis: its step is reading it
566
+ // again, and the parts after it depend on it, so none of theirs is named either. Coding accounts set
567
+ // their own unreadable step in `accountsNext`.
568
+ const actionFor = (p) =>
569
+ p.verdict === "unreadable" && p.part !== "coding accounts"
570
+ ? (p.next ?? `run this again; the ${p.part} part could not be read, so no step after it can be named yet`)
571
+ : NEXT[p.part];
572
+ let next =
438
573
  stuck === null
439
574
  ? null
440
- : { part: stuck.part, action: NEXT[stuck.part], owner: stuck.owner, where: stuck.where, blocking: stuck.blocking };
575
+ : { part: stuck.part, action: actionFor(stuck), owner: stuck.owner, where: stuck.where, blocking: stuck.blocking };
576
+
577
+ // A refused contract version outranks every step above: each of them was read from, or waits on, a
578
+ // contract this CLI would not accept. Whether an update exists is asked of npm, once, and only here.
579
+ function versionAdvice() {
580
+ const semver = (t) => (t ?? "").match(/\b(\d+)\.(\d+)\.(\d+)\b/);
581
+ const installed = semver(field("Bundle"));
582
+ const npm = spawnSync("npm", ["view", "@catalyst-cloud/cli", "version"], { encoding: "utf8", timeout: 10_000, shell: process.platform === "win32" });
583
+ const latest = npm.error || npm.status !== 0 ? null : semver(npm.stdout);
584
+ // The installer is the update path, not `catalyst-skills install`. Its command is on the app's
585
+ // setup page; this names the script it fetches rather than composing the request here.
586
+ const reinstall = `re-run the install command from the app's setup page (it installs from ${link("/install.sh")})`;
587
+ if (installed === null || latest === null) {
588
+ 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 };
589
+ }
590
+ const order = [1, 2, 3].map((i) => Math.sign(Number(installed[i]) - Number(latest[i]))).find((d) => d !== 0) ?? 0;
591
+ return order >= 0
592
+ ? { text: "A newer Catalyst CLI isn't published yet. Tell the Catalyst team; nothing on this machine needs to change.", owner: "the Catalyst team" }
593
+ : { text: `Update the CLI: ${reinstall}.`, owner: "you, on this machine" };
594
+ }
595
+ if (contractMismatch !== null && stuck !== null) {
596
+ const advice = versionAdvice();
597
+ const line = `the tenant serves contract ${contractMismatch.served} and this CLI accepts ${contractMismatch.accepts}. ${advice.text}`;
598
+ stuck.lines.push(line);
599
+ next = { part: stuck.part, action: line, owner: advice.owner, where: null, blocking: true };
600
+ }
441
601
  const finished = parts.every((p) => p.verdict === "ok");
442
602
 
443
603
  if (flags.json) {
444
- console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished }));
604
+ console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished, notes: retireNote === null ? [] : [retireNote] }));
445
605
  } else if (flags.next) {
446
- 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.");
606
+ process.on("exit", () => { if (retireNote !== null) console.log(`note: ${retireNote}`); });
607
+ 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).");
447
608
  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}` : ""}`);
448
609
  } else {
449
610
  for (const p of parts) {
@@ -456,6 +617,6 @@ if (flags.json) {
456
617
  if (p.verdict !== "ok" && p.where) console.log(` ${p.where.startsWith("http") ? "where" : "do"}: ${p.where}`);
457
618
  console.log("");
458
619
  }
459
- 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}` : ""}`);
620
+ 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}` : ""}`);
460
621
  }
461
622
  process.exit(finished ? 0 : 1);
@@ -4,7 +4,7 @@ description: >-
4
4
  Am I set up? Machine readiness (Node, the tenant connection, the cached contract, the CLI path, the skills, the SDK, the optional replica) plus tenant readiness from the contract's per-team checks, in one verdict: what passes, what is blocked, what is merely waiting, and who can click what. Use when someone asks "am I set up", "what is missing", "why does nothing happen", "is the replica running", or right after connecting a new machine. Reports; never repairs.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
8
8
 
9
9
  # Am I set up?
10
10
 
@@ -5,7 +5,7 @@ description: >-
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
9
9
 
10
10
  # Connect me
11
11
 
@@ -4,7 +4,7 @@ description: >-
4
4
  How Catalyst Cloud runs a ticket on the customer's own tenant, as facts an agent loads on demand: the eight-phase ladder and what each phase produces, the eleven board slots and this team's live stage map, what happens when a phase fails (retry, backoff, Remediate, park), how the queue is ordered and routed and every reason a ticket is excluded, and the coding-account model. Use when a person asks "how does this work?", "why did it do that?", "why is this stuck?", "what runs next?" or "how does it prioritise?". Read-only; its scripts explain one ticket's eligibility in plain English, show what is running and queued, and print the tenant's stage map and thresholds straight from the contract.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
8
8
 
9
9
  # How Catalyst works
10
10
 
@@ -5,7 +5,7 @@ description: >-
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
9
9
 
10
10
  # Run this project
11
11
 
@@ -5,7 +5,7 @@ description: >-
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
9
9
 
10
10
  # Unstick
11
11
 
@@ -5,7 +5,7 @@ description: >-
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
9
9
 
10
10
  # What needs me
11
11
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The desk for a Catalyst Cloud tenant. Use when the person asks "what's happening?", "where are we?", "why is that stuck?", "what closed?", "what's next?", or asks for something to be done rather than known. Reads the tenant contract, what is running and queued, the eligibility explainer and the open asks through the catalyst-skills CLI, and answers in one reply with ticket ids. Routes work to a project owner and decisions to what-needs-me. Never composes a URL, never polls, never answers as the human.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
8
8
 
9
9
  # What's happening
10
10