@catalyst-cloud/cli 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3
4
+
5
+ A machine the Catalyst installer moved from `@catalyst-cloud/catalyst-skills` to `@catalyst-cloud/cli` works without logging in again. The recorded launcher pointed into the removed package, so `ready` failed its CLI-path check and skill scripts could not start the CLI; any command now repoints it at the launcher that is running. The CLI also leaves the installer's skills alone: it never writes through a symlinked skill folder, and when the installer owns skill placement it does not refresh skills at all. When a refresh does fail, it tells you to re-run the install command.
6
+
7
+ Onboarding no longer tells you to re-credential an ended or cancelled coding account: it says to retire it on the AI accounts page, and such an account no longer leaves setup unfinished. Before you mint a replacement token, it has you check that your terminal is logged into the account the slot belongs to. `catalyst accounts` names each account by its label, else its email, with the slot id second, adds `displayName` to `--json`, and marks a cancelled subscription "subscription canceled — retire on the AI accounts page".
8
+
9
+ ## 0.9.2
10
+
11
+ The CLI works against a tenant serving contract 2.x. Catalyst Cloud's tenant contract moved to 2.0.0 when it removed `merge.cloudRemediateRequiredChecks`, and every earlier bundle accepted only 1.x, so `ready`, onboarding and most commands stopped with "the tenant serves contract version 2.2.0 but this bundle accepts 1.x". This bundle accepts `1.x || 2.x`. The one place that read the removed field already treated it as absent.
12
+
3
13
  ## 0.9.1
4
14
 
5
15
  Onboarding now teaches `.catalyst/catalyst.toml`, the only file the cloud reads a repository's settings from. The skills still described `catalyst.env.json`, which the cloud has ignored since 2026-09-27, so a declaration written from them was silently dropped. The repository reference shows the file's shape (`[project]` with `linear_team`, then `[[environment.variables]]` and `[[environment.setup]]`) and the approval step: an owner or admin opens the repository's Environment page, goes to the Setup declaration tab, and clicks Approve this revision.
package/README.md CHANGED
@@ -169,7 +169,7 @@ Your personal key reads everything the skills need — tickets, pull requests, t
169
169
 
170
170
  ## Versions and origins
171
171
 
172
- The package pins the tenant contract range `1.x`, recorded in `package.json` under `catalystCloud.tenantContractRange`, and reports it in `--version`, `status` and `login`. A tenant whose contract version falls outside that range is refused with one line naming both versions; update the bundle. Every skill carries a `vendored-from:` line naming this package as its origin, and that line now also carries the version it was vendored at; all of them are written in this repository for customer tenants.
172
+ The package pins the tenant contract range `1.x || 2.x`, recorded in `package.json` under `catalystCloud.tenantContractRange`, and reports it in `--version`, `status` and `login`. A tenant whose contract version falls outside that range is refused with one line naming both versions; update the bundle. Every skill carries a `vendored-from:` line naming this package as its origin, and that line now also carries the version it was vendored at; all of them are written in this repository for customer tenants.
173
173
 
174
174
  ## What it writes on your machine
175
175
 
@@ -209,7 +209,7 @@ npm uninstall -g @catalyst-cloud/catalyst-skills
209
209
  - `catalyst-skills: GET /me failed (401): credential not accepted — mint a personal key at Settings → API keys and log in again` — the key is stale, mistyped or revoked. Mint a new one, then run `login` again.
210
210
  - `catalyst-skills: GET /me failed (403): account-not-operational` — the tenant is suspended. This is an admin conversation on the tenant, not a local fix.
211
211
  - `catalyst-skills: could not reach <url>: <detail>` — the machine cannot reach the cloud. The URL is named in the message; check `CATALYST_CLOUD_BASE_URL` or `--base-url`.
212
- - A line naming two contract versions after `Connected to` — the tenant serves a contract outside this bundle's `1.x` range. The config is written; update the bundle before using the other skills.
212
+ - A line naming two contract versions after `Connected to` — the tenant serves a contract outside this bundle's `1.x || 2.x` range. The config is written; update the bundle before using the other skills.
213
213
  - `[catalyst-skills] GET /api/v1/agent/contract refused (403): this cloud is older than the bundle …` on stderr — the cloud has not yet deployed personal-key access to the contract. Update the cloud, or connect with the tenant's account key until it has.
214
214
 
215
215
  ## License
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
3
3
  import { existsSync } from "node:fs";
4
4
  import { parseArgs, positionals, verbHelp } from "./args.js";
5
- import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
5
+ import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
6
6
  import { loadContract, pickPath } from "./contract.js";
7
7
  import { CliError, MeError, UsageError } from "./errors.js";
8
8
  import { fetchMe } from "./transport.js";
@@ -100,8 +100,14 @@ function migrateLegacyCliPath(ctx) {
100
100
  }
101
101
  if (!cfg)
102
102
  return;
103
- const next = modernCliPath(cfg.cliPath);
104
- if (next === null)
103
+ let next = modernCliPath(cfg.cliPath);
104
+ // An upgrade from @catalyst-cloud/catalyst-skills to @catalyst-cloud/cli removes the old package
105
+ // folder, so the recorded launcher no longer exists and has no sibling. Point it at the launcher
106
+ // that is running now, so `ready` and every skill script work without another login.
107
+ if (next === null && typeof cfg.cliPath === "string" && cfg.cliPath !== "" && !existsSync(cfg.cliPath)) {
108
+ next = cliPath();
109
+ }
110
+ if (next === null || next === cfg.cliPath)
105
111
  return;
106
112
  cfg.cliPath = next;
107
113
  try {
@@ -134,13 +140,23 @@ function maybePrintUpdateNotice(args, ctx) {
134
140
  const entry = parseChangelogEntry(readChangelog(), manifest.version);
135
141
  say(updateNoticeLine(previous, manifest.version, entry));
136
142
  }
143
+ if (installerOwnsSkills(ctx.home, ctx.env)) {
144
+ cfg.lastSkillBundleVersion = manifest.version;
145
+ try {
146
+ saveConfig(ctx.home, cfg);
147
+ }
148
+ catch {
149
+ // Best effort: the notice prints again next time, and nothing else depends on it.
150
+ }
151
+ return;
152
+ }
137
153
  const skillsDir = resolveSkillsDir(args, ctx, cfg);
138
154
  let refreshed;
139
155
  try {
140
156
  refreshed = installSkills(skillsDir, { force: false, onlyExisting: true });
141
157
  }
142
158
  catch (err) {
143
- ctx.stderr(`[catalyst-skills] could not refresh the skills at ${skillsDir} (${err instanceof Error ? err.message : String(err)}) — the ${manifest.version} skills are not installed yet; run: catalyst-skills install`);
159
+ ctx.stderr(`[catalyst-skills] could not refresh the skills at ${skillsDir} (${err instanceof Error ? err.message : String(err)}) — the ${manifest.version} skills are not installed yet; re-run the install command: ${INSTALL_COMMAND}`);
144
160
  return;
145
161
  }
146
162
  if (refreshed.installed.length > 0)
package/dist/config.js CHANGED
@@ -91,6 +91,8 @@ export function updatePackageName(root = packageRoot()) {
91
91
  return LEGACY_PACKAGE_NAME;
92
92
  return PACKAGE_NAME;
93
93
  }
94
+ /** The Catalyst installer, which owns skill placement and switches a machine between packages. */
95
+ export const INSTALL_COMMAND = "curl -fsSL https://staging.catalystcloud.dev/install.sh | sh";
94
96
  /** The one upgrade command every hint prints: update the installed package, then log in again so
95
97
  * customer.json records the new launcher path. */
96
98
  export function upgradeCommand(root) {
@@ -115,6 +117,20 @@ export function normalizeBaseUrl(url) {
115
117
  export function apiBase(cfg) {
116
118
  return `${normalizeBaseUrl(cfg.baseUrl)}/api/v1`;
117
119
  }
120
+ /** True when the Catalyst installer owns skill placement on this machine: it exports
121
+ * CATALYST_SKILLS_DIR, or it wrote the machine paths file (every v1 record carries a skills role).
122
+ * The CLI then never refreshes skill folders itself; the installer's own refresh does. */
123
+ export function installerOwnsSkills(home, env = {}) {
124
+ if (env.CATALYST_SKILLS_DIR !== undefined && env.CATALYST_SKILLS_DIR !== "")
125
+ return true;
126
+ try {
127
+ const file = machinePathsFile({ env: { ...env, HOME: home } });
128
+ return Boolean(file && machineFilePresent(file));
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
118
134
  function machineFilePresent(file) {
119
135
  try {
120
136
  lstatSync(file);
package/dist/contract.js CHANGED
@@ -29,8 +29,15 @@ export function writeContractCache(home, cache) {
29
29
  renameSync(tmp, path);
30
30
  return path;
31
31
  }
32
- /** `1.x` → major 1; `1.2.3` → exact; `unpinned` → anything. Returns null when the range is unparseable. */
32
+ /** `1.x` → major 1; `1.2.3` → exact; `unpinned` → anything; `1.x || 2.x` → any alternative matches.
33
+ * Returns null when the range (or any alternative) is unparseable. */
33
34
  export function contractVersionInRange(version, range) {
35
+ if (range.includes("||")) {
36
+ const results = range.split("||").map((part) => contractVersionInRange(version, part.trim()));
37
+ if (results.some((r) => r === null))
38
+ return null;
39
+ return results.some((r) => r === true);
40
+ }
34
41
  if (range === "unpinned")
35
42
  return true;
36
43
  const major = /^(\d+)\.x$/.exec(range);
package/dist/execution.js CHANGED
@@ -312,18 +312,29 @@ export async function cmdQueue(args, ctx) {
312
312
  }
313
313
  /** The five status words `/api/v1/coding-accounts` reports, in the words a customer reads. */
314
314
  export const ACCOUNT_STATUS = {
315
- "expired-or-revoked": "expired or revoked — re-enrol it before it can take work",
315
+ "expired-or-revoked": "expired or revoked — replace its credential on the AI accounts page (Settings → AI accounts → the account → Replace credential) before it can take work",
316
316
  walled: "walled — the provider's usage limit is spent for now",
317
317
  active: "active — observed working",
318
318
  attested: "attested — healthy at last check, no work observed since",
319
319
  unobserved: "unobserved — enrolled, but nothing has been seen from it yet",
320
320
  };
321
+ /** The name a person or agent calls an account by: its label, else its email, else its slot id. */
322
+ export function accountDisplayName(a) {
323
+ for (const v of [a.label, a.email])
324
+ if (typeof v === "string" && v.trim() !== "")
325
+ return v.trim();
326
+ return a.accountSlot ?? "?";
327
+ }
321
328
  /** One slot as a line a human reads: who it is, what state it is in, and what it is spending on. */
322
329
  export function renderAccount(a) {
323
- const name = a.label ? `${a.accountSlot ?? "?"} (${a.label})` : (a.accountSlot ?? "?");
330
+ const display = accountDisplayName(a);
331
+ const name = display === (a.accountSlot ?? "?") ? display : `${display} (${a.accountSlot ?? "?"})`;
324
332
  const harness = a.harness ? `/${a.harness}` : "";
325
333
  const status = a.status ? (ACCOUNT_STATUS[a.status] ?? a.status) : "status unknown";
326
334
  const bits = [`${name} ${a.provider ?? "?"}${harness} ${status}`];
335
+ // CTC-4199: a cancelled subscription is retired, never re-credentialed; say so in the text view too.
336
+ if (a.renewalStatus === "canceled")
337
+ bits.push("subscription canceled — retire on the AI accounts page");
327
338
  if (typeof a.bindingUsedPercent === "number") {
328
339
  const resets = typeof a.bindingResetsAtMs === "number" ? `, resets ${new Date(a.bindingResetsAtMs).toISOString()}` : "";
329
340
  bits.push(`usage ${a.bindingUsedPercent}% of the ${a.bindingWindow ?? "binding"} window${resets}`);
@@ -356,7 +367,8 @@ export async function cmdAccounts(args, ctx) {
356
367
  throw needsNewerCloud("coding-account status", cfg);
357
368
  const accounts = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
358
369
  if (args.json) {
359
- ctx.stdout(JSON.stringify(res.body));
370
+ const named = accounts.map((a) => ({ ...a, displayName: accountDisplayName(a) }));
371
+ ctx.stdout(JSON.stringify(Array.isArray(res.body) ? named : { ...res.body, accounts: named }));
360
372
  return 0;
361
373
  }
362
374
  if (accounts.length === 0) {
package/dist/oauth.js CHANGED
@@ -65,41 +65,66 @@ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
65
65
  }
66
66
  return doc;
67
67
  }
68
+ /**
69
+ * CTC-2136: how many device codes one login mints before it gives up. A code lives `expires_in`
70
+ * (300 s at WorkOS), which a first sign-in from a phone can outlast, so an expired code is replaced
71
+ * in the same process rather than ending the login. Ryan's call (2026-09-28): up to 3 codes.
72
+ */
73
+ export const MAX_DEVICE_CODES = 3;
68
74
  /**
69
75
  * Run the device-authorization grant to completion and return the stored session block. Prints the
70
- * user code and where to enter it; on a TTY it also opens the completion URL. Polls at the server's
71
- * `interval`, honours `slow_down`, and is bounded by `AbortSignal.timeout(expires_in)` so it can
72
- * never poll past the device code's own lifetime.
76
+ * user code, where to enter it, and the one-click link when the server sends one; on a TTY it also
77
+ * opens the browser. Each code is polled at the server's `interval`, honouring `slow_down`, and is
78
+ * bounded by `AbortSignal.timeout(expires_in)` so it can never poll past its own lifetime. When a code
79
+ * expires, a fresh one is minted and printed, up to MAX_DEVICE_CODES in all.
73
80
  */
74
81
  export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
75
82
  const sleep = deps.sleep ?? defaultSleep;
76
83
  const discovery = await fetchDiscovery(ctx, baseUrl);
77
- const auth = await deviceAuthorize(ctx, discovery);
78
- ctx.stdout("");
79
- ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
80
- ctx.stdout(`and enter the code: ${auth.user_code}`);
81
- if ((deps.isTty ?? (() => false))()) {
82
- try {
83
- (deps.openBrowser ?? (() => { }))(auth.verification_uri_complete);
84
- ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
85
- }
86
- catch {
87
- // a browser that will not open is not a failure; the code and URL still work
84
+ // The browser rule is decided on the first code; a later code re-opens it only when the first did.
85
+ let browserOpened = false;
86
+ for (let round = 1; round <= MAX_DEVICE_CODES; round++) {
87
+ const auth = await deviceAuthorize(ctx, discovery);
88
+ if (round > 1)
89
+ ctx.stdout(`That code expired. Here is a new one (${round} of ${MAX_DEVICE_CODES}):`);
90
+ ctx.stdout("");
91
+ ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
92
+ ctx.stdout(`and enter the code: ${auth.user_code}`);
93
+ if (auth.verification_uri_complete)
94
+ ctx.stdout(`Or open this link, which fills the code in: ${auth.verification_uri_complete}`);
95
+ if (round === 1 ? (deps.isTty ?? (() => false))() : browserOpened) {
96
+ try {
97
+ (deps.openBrowser ?? (() => { }))(auth.verification_uri_complete ?? auth.verification_uri);
98
+ browserOpened = true;
99
+ ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
100
+ }
101
+ catch {
102
+ // a browser that will not open is not a failure; the code and URL still work
103
+ }
88
104
  }
105
+ ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
106
+ const tokens = await pollDeviceCode(ctx, discovery, auth, sleep);
107
+ if (tokens !== "expired")
108
+ return tokensToAuth(ctx, tokens.access_token, tokens.refresh_token);
89
109
  }
90
- ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
91
- // Terminal states print ONE clear, actionable line and exit 2 (CliError's default). The duration
92
- // comes from the device code's own `expires_in`, so it is right whatever the server set.
93
- const mins = Math.round(auth.expires_in / 60);
94
- const expired = () => new CliError(`the login code expired${mins >= 1 ? ` after ${mins} minute${mins === 1 ? "" : "s"}` : ""} — run: catalyst-skills login again`, "login-expired");
110
+ // Every code lapsed unapproved. The exit code (2, CliError's default) and the `login-expired` code are
111
+ // unchanged from before CTC-2136; only the line is new, and its wording is Ryan's.
112
+ throw new CliError(`The sign-in code expired ${MAX_DEVICE_CODES} times. Run the same command again when you are ready to approve.`, "login-expired");
113
+ }
114
+ /**
115
+ * Poll one device code until it yields a token pair or expires. Terminal refusals (`access_denied`,
116
+ * a non-transient error) throw; expiry, by the server's `expired_token` or the code's own
117
+ * `expires_in` lapsing, returns "expired" so the caller can mint the next code.
118
+ */
119
+ async function pollDeviceCode(ctx, discovery, auth, sleep) {
95
120
  const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
96
121
  let intervalMs = Math.max(1, auth.interval) * 1_000;
97
122
  for (;;) {
98
123
  await sleep(intervalMs);
99
- // The device code's own lifetime bounds the loop: once it lapses, say so plainly rather than
124
+ // The device code's own lifetime bounds the loop: once it lapses, treat it as expired rather than
100
125
  // letting the next fetch's aborted signal surface as a generic network error (Codex P2).
101
126
  if (deadline.aborted)
102
- throw expired();
127
+ return "expired";
103
128
  let res;
104
129
  try {
105
130
  res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, deadline);
@@ -108,7 +133,7 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
108
133
  // The deadline firing mid-fetch is an expiry, not a failure; anything else is a transient
109
134
  // network blip — retry with backoff until the deadline, never abandon the login (CTC-2112 P1).
110
135
  if (deadline.aborted)
111
- throw expired();
136
+ return "expired";
112
137
  intervalMs = backoff(intervalMs);
113
138
  continue;
114
139
  }
@@ -117,7 +142,7 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
117
142
  if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
118
143
  throw new CliError("the login token response was missing a token", "login-shape");
119
144
  }
120
- return tokensToAuth(ctx, pair.access_token, pair.refresh_token);
145
+ return { access_token: pair.access_token, refresh_token: pair.refresh_token };
121
146
  }
122
147
  const err = await oauthError(res, discovery.tokenUrl);
123
148
  if (err === "authorization_pending")
@@ -129,7 +154,7 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
129
154
  if (err === "access_denied")
130
155
  throw new CliError("login was denied — run: catalyst-skills login to try again", "login-denied");
131
156
  if (err === "expired_token")
132
- throw expired();
157
+ return "expired";
133
158
  // A transient HTTP failure (request timeout, rate limit, server error) is retried, honouring
134
159
  // Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
135
160
  if (res.status === 408 || res.status === 429 || res.status >= 500) {
@@ -178,7 +203,7 @@ async function deviceAuthorize(ctx, discovery) {
178
203
  device_code: d.device_code,
179
204
  user_code: d.user_code,
180
205
  verification_uri: d.verification_uri,
181
- verification_uri_complete: d.verification_uri_complete ?? d.verification_uri,
206
+ ...(typeof d.verification_uri_complete === "string" && d.verification_uri_complete !== "" ? { verification_uri_complete: d.verification_uri_complete } : {}),
182
207
  expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
183
208
  interval: typeof d.interval === "number" ? d.interval : 5,
184
209
  };
package/dist/skills.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // skills.ts — copying the bundled skills into the user's skills directory, and the one-line update
2
2
  // notice a new version prints on its next session.
3
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { defaultSkillsDirFor, upgradeCommand } from "./config.js";
@@ -31,6 +31,10 @@ export function installSkills(targetDir, opts, sourceDir = skillsSourceDir()) {
31
31
  if (!existsSync(join(src, "SKILL.md")))
32
32
  continue;
33
33
  const dst = join(targetDir, name);
34
+ // A symlinked entry belongs to whoever linked it (the Catalyst installer links its skills in);
35
+ // this package never writes through one, and says nothing about it.
36
+ if (isSymlink(dst))
37
+ continue;
34
38
  const existingMd = join(dst, "SKILL.md");
35
39
  if (opts.onlyExisting && !existsSync(existingMd))
36
40
  continue;
@@ -47,6 +51,14 @@ export function installSkills(targetDir, opts, sourceDir = skillsSourceDir()) {
47
51
  }
48
52
  return result;
49
53
  }
54
+ function isSymlink(path) {
55
+ try {
56
+ return lstatSync(path).isSymbolicLink();
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
50
62
  export function resolveSkillsDir(args, ctx, cfg) {
51
63
  return args.skillsDir ?? ctx.env.CATALYST_SKILLS_CLAUDE_DIR ?? cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
52
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat — get set up, see what is happening, see what needs you, run a project, unstick what is stuck — plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command. The skills directory is the roster; this line never counts it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "vendor"
33
33
  ],
34
34
  "catalystCloud": {
35
- "tenantContractRange": "1.x",
35
+ "tenantContractRange": "1.x || 2.x",
36
36
  "pinnedNode": "24.21.0"
37
37
  },
38
38
  "scripts": {
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.1 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — written in this repository for customer tenants -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -16,7 +16,7 @@ Merge evidence has three independent legs. Two are unconditional under every pol
16
16
  | reviewer | the reviewer signal the policy accepts (below) | only under a reviewer-attestation policy |
17
17
  | threads | zero unresolved review threads, and no resolved thread whose resolving commit has left the head | yes |
18
18
 
19
- Which checks are gating: the names the queue configuration itself requires, plus any extra gating check the cloud names. The contract serves the checks the cloud's own remediation waits on as `cloudRemediateRequiredChecks`; a check that cannot block a merge is informational and never a reason to withhold the label.
19
+ Which checks are gating: the names the queue configuration itself requires, plus any extra gating check the cloud names. A 1.x contract also serves the checks the cloud's own remediation waits on as `cloudRemediateRequiredChecks`; a 2.x contract no longer carries it, so only the queue configuration's names gate there; a check that cannot block a merge is informational and never a reason to withhold the label.
20
20
 
21
21
  ## The policies
22
22
 
@@ -1,6 +1,6 @@
1
1
  # What a pull request accumulates as Catalyst moves a ticket
2
2
 
3
- Most of this page restates invariants: how Catalyst names branches, opens and rewrites pull requests, and what it pushes when. The parts that vary per tenant are read live from the contract's `merge` block and never restated here: the reviewer's login (`merge.reviewerLogin`), which clean-pass shapes are honoured (`merge.cleanPassShapes[]`), the four PR label names (`merge.prLabels`), the checks the cloud waits on (`merge.cloudRemediateRequiredChecks`), and the policy per repository (`merge.repositories[]`, else `merge.defaultPolicy`). `catalyst-skills contract --path merge` prints them.
3
+ Most of this page restates invariants: how Catalyst names branches, opens and rewrites pull requests, and what it pushes when. The parts that vary per tenant are read live from the contract's `merge` block and never restated here: the reviewer's login (`merge.reviewerLogin`), which clean-pass shapes are honoured (`merge.cleanPassShapes[]`), the four PR label names (`merge.prLabels`), the checks the cloud waits on (`merge.cloudRemediateRequiredChecks`, on a 1.x contract only), and the policy per repository (`merge.repositories[]`, else `merge.defaultPolicy`). `catalyst-skills contract --path merge` prints them.
4
4
 
5
5
  ## The branch
6
6
 
@@ -5,7 +5,7 @@ description: >-
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  disable-model-invocation: true
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.1 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — written in this repository for customer tenants -->
9
9
 
10
10
  # Catalyst Linear
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.1 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — written in this repository for customer tenants -->
9
9
 
10
10
  # Onboard me
11
11
 
@@ -13,6 +13,8 @@ You take one person from "the skills are installed" to "a ticket is running on m
13
13
 
14
14
  Setup has seven parts and each is read by its own instrument: this **machine**, the **person**, the **account**, one **project** (a project is one Linear team), one **repository**, the **coding accounts** a phase runs on, and the **host**. Never say work can run while either of the last two is unfinished. A failure in one is not a failure in another, and a failing part names who can fix it and where. Getting that wrong is the one mistake that wastes a person's afternoon: a project problem reported as a machine problem sends them retrying a local command that was never going to help.
15
15
 
16
+ The path also has the person confirm their org's `<org>/thoughts` repository and give the GitHub App access to it (step 5a). The script can show that repository exists, never that the App can reach it.
17
+
16
18
  ## Run first
17
19
 
18
20
  Scripts are run, never read. Each prints `--help`.
@@ -37,6 +39,7 @@ Start every session with it, and run it again after every step the person comple
37
39
  | the person asks how to install, update, or migrate Catalyst skills | `references/skill-sources.md` |
38
40
  | the person's repository needs environment names declared | `references/declaring-a-repository.md` |
39
41
  | the `coding accounts` or `host` part is not ok, or you are about to say work can run | `references/what-a-phase-needs.md` |
42
+ | the script says a coding account needs a new credential | `references/replacing-a-credential.md` |
40
43
  | setup is finished and they want the standing readiness verdict | the `catalyst-setup` skill |
41
44
  | the first ticket did not start and you need the reason | the `how-catalyst-works` skill, then `unstick` |
42
45
 
@@ -0,0 +1,51 @@
1
+ # Replacing a coding account's credential
2
+
3
+ When the script prints "<provider> account <slot> needs a new credential", replace that account's credential on its own page. Do not enroll a second account. A second account is a duplicate, and the dead one stays in place.
4
+
5
+ ## Why an account needs one
6
+
7
+ The script flags an account in three cases:
8
+
9
+ - it is quarantined;
10
+ - it is expired or revoked;
11
+ - its last polls failed on the credential itself, such as `no_access_token`, three or more times in a row. The script can see this only when the cloud reports each account's polls.
12
+
13
+ Other accounts can be healthy at the same time. The contract can still say `enrolled`, so that line alone never proves every account works.
14
+
15
+ ## Who does it
16
+
17
+ A tenant owner or admin, in the browser. The person copies the credential and pastes it. You never read it, print it, or paste it. The script prints the page link; use it, and never type a host.
18
+
19
+ ## Codex
20
+
21
+ The person runs these steps on their own machine:
22
+
23
+ 1. Run `codex login` and choose "Sign in with ChatGPT".
24
+ 2. The login writes `~/.codex/auth.json`. If `CODEX_HOME` is set, the file is `$CODEX_HOME/auth.json`. On macOS, `pbcopy < ~/.codex/auth.json` copies it.
25
+ 3. In the browser, open Settings → AI accounts, then open that Codex account.
26
+ 4. Paste the file into "Replacement auth.json contents", then press Replace credential.
27
+
28
+ Then you run `node scripts/where-am-i.mjs` again and read back the `coding accounts` part.
29
+
30
+ Say these two things plainly:
31
+
32
+ - Do not enroll a second account. Replace the credential on the one the script named.
33
+ - Catalyst takes over this login. Its refresh token is single-use, so the copy on this machine stops working once Catalyst uses it. To use Codex locally, sign in to Codex again, separately, after the replacement.
34
+
35
+ ## Claude
36
+
37
+ The same page takes a Claude account's new credential. Its field is "Replacement setup token". `claude setup-token` mints a token for whichever Claude account the terminal is logged into, not for the slot. So first match them: read the slot's account on the AI accounts page (its label or email), then have the person run `claude` and `/status` to see the email the terminal is logged into. If they differ, stop, and log in to the slot's account first. Only then run `claude setup-token`, paste the token into that field, and press Replace credential. Then re-run the script.
38
+
39
+ ## An ended or cancelled account
40
+
41
+ An account whose subscription is cancelled, or which has ended, is not a credential problem, and no token revives it. Retire it on the AI accounts page. Never replace its credential with a token from another account: that puts a working account's login onto a dead slot.
42
+
43
+ ## Any other provider
44
+
45
+ Replace its credential on the same page, with the credential that page asks for.
46
+
47
+ ## What Replace credential does
48
+
49
+ It stores the new credential and clears the account's quarantine in the same step. A tenant owner or admin can do it. An operator is not needed.
50
+
51
+ A failed poll is recorded until the account is polled again. If the script still names the account right after the replacement, wait for the next poll and read it again. If it still names it, report both: what the page said, and what the script says.
@@ -1,12 +1,11 @@
1
1
  # The one path
2
2
 
3
- Eleven steps, in this order. The order is the product's own: the tenant-side steps run Linear, then the project, then GitHub, then the repository, because each one is the cheapest place to catch the failure the next one would otherwise hide. Connect the tenant's Linear workspace before starting personal Linear consent (3a). Install the tenant's GitHub App and register its repository before starting personal GitHub consent (6a).
3
+ The steps run in this order. The order is the product's own: the tenant-side steps run Linear, then the project, then GitHub, then the repository, because each one is the cheapest place to catch the failure the next one would otherwise hide. Connect the tenant's Linear workspace before starting personal Linear consent (3a). Install the tenant's GitHub App and register its repository before starting personal GitHub consent (6a).
4
4
 
5
5
  Walk them **one at a time**. Before each step say what you are about to do and why; after it, show what actually came back. `node scripts/where-am-i.mjs --next` decides which step you are on — never your memory of the last turn.
6
6
 
7
7
  Each step below states: what it is for, what you run or hand over, **what you read back to prove it landed**, and **who owns it**.
8
8
 
9
-
10
9
  ## 0 — Where are we
11
10
 
12
11
  **For:** starting from the truth instead of from an assumption. A person arrives here having done anything from nothing to most of it.
@@ -71,17 +70,19 @@ If it refuses, stop here and use the `connect-me` skill; it owns every failure m
71
70
 
72
71
  ⭐ **One project at a time is safe, and lead with this.** Mapping one project changes no other project's stages and moves no other project's tickets. Encourage a pilot: pick the project they care least about breaking.
73
72
 
74
-
75
73
  ## 5 — Install the GitHub App
76
74
 
77
75
  **For:** the account grain again. Without it Catalyst can read tickets but cannot touch code.
78
76
 
79
- **You hand over:** `<their cloud>/settings/connections`, and say: install the GitHub App, and grant it the repository they want worked.
77
+ **You hand over:** `<their cloud>/settings/connections`, and say: install the GitHub App, and grant it the repository they want worked and `<org>/thoughts` (step 5a).
80
78
 
81
79
  **Read back:** it is confirmed by step 6 succeeding — a repository cannot be registered through an app that is not installed. Say that is what you are waiting for rather than claiming you verified it here.
82
80
 
83
81
  **Owner:** a tenant owner or admin, in a browser. **Browser by construction**, same reason as step 3.
84
82
 
83
+ ## 5a — The thoughts repository
84
+
85
+ **For:** Catalyst's cloud phases write their notes to `<org>/thoughts`, where `<org>` is the GitHub org that owns the code repository. If the App cannot reach it, phases run without those notes and nothing says so. **You hand over:** GitHub. Confirm `<org>/thoughts` exists; if not, create a private repository named `thoughts`, initialized with a README. Then, on the GitHub App's installation page for that org, choose All repositories, or add `thoughts` to the selected repositories. **Read back:** once a repository is registered, the script's `repositories` part carries a note from `gh repo view <org>/thoughts`. It shows the repository exists, never that the App can reach it. Say that plainly; never claim App access is verified. **Owner:** an org admin on GitHub, in a browser.
85
86
 
86
87
  ## 6 — Register the repository
87
88
 
@@ -93,7 +94,6 @@ If it refuses, stop here and use the `connect-me` skill; it owns every failure m
93
94
 
94
95
  **Owner:** a tenant owner or admin. ⛔ **Registering is settings-page work today**; a key-callable path is being built. ⛔ A repository registered without a project attached is the trap here: the call succeeds, the repository is listed, and nothing can ever dispatch into it. Make sure they attach the project in the same form, and say why.
95
96
 
96
-
97
97
  ## 6a — Connect your personal GitHub account
98
98
 
99
99
  **For:** letting Catalyst act as you in GitHub. This grant is separate from the tenant's GitHub App installation. **You run:** `catalyst-skills connections personal github start`, only after the GitHub App is installed and the repository is registered. The browser flow is the same as 3a. **Read back:** `catalyst-skills connections personal github status`; a connected result confirms the personal grant. **Owner:** you start and check; the member approves in a browser.
@@ -122,12 +122,11 @@ Add `--approve` to approve exactly the revision that propose just returned, whic
122
122
 
123
123
  If they do not know what their build needs yet, skip this step. It blocks nothing until a phase needs a secret.
124
124
 
125
-
126
125
  ## 8 — A coding account, and a host
127
126
 
128
127
  **For:** what a phase runs on. Without an enrolled coding account and a passing host check, every step above can be done and nothing starts.
129
128
 
130
- **You run:** it is already in the script, as the `coding accounts` and `host` parts. What each reading means, who owns it, and what to hand over are in `references/what-a-phase-needs.md`. The contract names who enrols the account and who owns the host.
129
+ **You run:** it is already in the script, as the `coding accounts` and `host` parts. What each reading means, who owns it, and what to hand over are in `references/what-a-phase-needs.md`. The contract names who enrols the account and who owns the host. If the script says an account needs a new credential, the steps are in `references/replacing-a-credential.md`.
131
130
 
132
131
  ---
133
132
 
@@ -143,7 +142,6 @@ If they do not know what their build needs yet, skip this step. It blocks nothin
143
142
 
144
143
  **Owner:** the card move is theirs. The verdict is the tenant's.
145
144
 
146
-
147
145
  ## When you are done
148
146
 
149
147
  Say what is set up, name anything still unfinished with its owner, and never say work can run while the `coding accounts` or `host` part is unfinished, and tell them the standing question "am I set up?" now belongs to the `catalyst-setup` skill, and "what's happening?" to `whats-happening`. You do not need to be invoked again.
@@ -6,13 +6,13 @@ A person can finish every other step and still see nothing run. A phase needs tw
6
6
 
7
7
  **For:** a phase runs on one of the tenant's own enrolled coding accounts. With none active, no phase can start.
8
8
 
9
- **Instrument:** `codingAccounts` in `catalyst-skills contract`. It carries a `state`, a printable `line`, who enrolls an account (`enrolledByLine`) and the `page`. The script prints all of them. It never shows a credential or an email.
9
+ **Instrument:** `codingAccounts` in `catalyst-skills contract`. It carries a `state`, a printable `line`, who enrolls an account (`enrolledByLine`) and the `page`. The script prints all of them. It never shows a credential or an email. When the state is `enrolled`, the script also reads `catalyst-skills accounts` to check each account's credential.
10
10
 
11
11
  **What each state means:**
12
12
 
13
13
  | state | what it says | what to do |
14
14
  | -- | -- | -- |
15
- | `enrolled` | at least one account is active | nothing |
15
+ | `enrolled` | at least one account is active. Another account can still have a dead credential | read the account lines. If one says "<provider> account <slot> needs a new credential", that is the next step: follow `references/replacing-a-credential.md`. If the part reads `unreadable`, the accounts could not be checked in detail; say so and read it again later. Otherwise nothing |
16
16
  | `none_enrolled` | no account is enrolled | hand over the page; the enroller the contract names enrols one |
17
17
  | `inactive` | accounts exist, but every one is out of rotation | reactivate one on the page. Never tell them to enrol another |
18
18
  | `unread` | the cloud could not read the accounts | say it could not be read. It is not "no accounts". Do not tell them to enrol one; read it again later |
@@ -10,7 +10,8 @@ These will never be a command, on any release. Each is an authorization a person
10
10
  | -- | -- | -- |
11
11
  | approving the login | the URL and short code that `catalyst-skills login` printed | "I have started the login. It printed this code and this URL — approve it in your browser, or on your phone, and tell me when it is done." |
12
12
  | connecting Linear | `<their cloud>/settings/connections` | "Open this page and connect Linear. It will send you to Linear to authorize it and bring you back." |
13
- | installing the GitHub App | `<their cloud>/settings/connections` | "Open the same page and install the GitHub App, granting it the repository you want worked." |
13
+ | installing the GitHub App | `<their cloud>/settings/connections` | "Open the same page and install the GitHub App, granting it the repository you want worked and `<your GitHub org>/thoughts`." |
14
+ | creating or confirming `<your GitHub org>/thoughts`, and letting the GitHub App reach it | GitHub, then the GitHub App's installation page for that org | "Catalyst's cloud phases write their notes to `<your GitHub org>/thoughts`, in the org that owns the code repository. Check it exists. If not, create a private repository named `thoughts`, initialized with a README. Then, on the GitHub App's installation page for that org, choose All repositories, or add `thoughts` to the selected repositories." |
14
15
  | connecting personal Linear | the URL printed by `catalyst-skills connections personal linear start` | "Approve your own Linear account in this browser. I will check the grant status afterward." |
15
16
  | connecting personal GitHub, after the tenant GitHub App is installed and its repository is registered | the URL printed by `catalyst-skills connections personal github start` | "Approve your own GitHub account in this browser. I will check the grant status afterward." |
16
17
 
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // Every count and every name below is read off what a verb printed. Nothing here is written down.
8
8
  import { cliTarget, CONNECT_LINE, parseFlags, printHelp, runCli, tryJson, tryLoadConfig } from "./lib/cli.mjs";
9
+ import { spawnSync } from "node:child_process";
9
10
  import { runLocalSync } from "./local-sync.mjs";
10
11
 
11
12
  const SPEC = {
@@ -16,8 +17,9 @@ const NOTES = [
16
17
  "Reads, in this order: `status` (machine), `ready --json` (machine checks and project checks, kept apart),",
17
18
  "`replica status --probe --json` and `events status --probe --json` (optional local freshness),",
18
19
  "`me --json` and personal connection statuses (person), `contract --path …` for the account, the projects and the repositories,",
19
- "`contract --path codingAccounts` (coding accounts; `accounts --json` on an older cloud), and each",
20
+ "`contract --path codingAccounts` and `accounts --json` (coding accounts, and which one needs a new credential), and each",
20
21
  "project's hosts_current check with its fixedWhere (host).",
22
+ "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.",
21
23
  "Writes nothing and changes nothing. Runs before this machine is connected — that is one of the states it reports.",
22
24
  ];
23
25
 
@@ -246,6 +248,17 @@ if (!connected) {
246
248
  }
247
249
 
248
250
  // ── repositories ──────────────────────────────────────────────────────────────────────────────────
251
+ // Cloud phases write their notes to <owner>/thoughts, where <owner> owns the code repository. The
252
+ // person's own `gh` can say whether that repository exists. It cannot say whether the GitHub App can
253
+ // reach it, so this note never claims that, and it never changes a verdict.
254
+ function thoughtsNote(owner) {
255
+ const repo = `${owner}/thoughts`;
256
+ const res = spawnSync("gh", ["repo", "view", repo, "--json", "name"], { encoding: "utf8", timeout: 15_000, env: { ...process.env, GH_PROMPT_DISABLED: "1" } });
257
+ if (res.error) return `note ${repo}: not checked (the gh command could not run here). Confirm it exists on GitHub (step 5a).`;
258
+ if (res.status === 0) return `note ${repo}: exists; App access not verifiable from here. Confirm the GitHub App installation includes it (step 5a).`;
259
+ const why = (res.stderr || res.stdout).trim().split("\n")[0] || `gh exited ${res.status}`;
260
+ 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
+ }
249
262
  if (!connected) {
250
263
  add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
251
264
  } else {
@@ -258,6 +271,7 @@ if (!connected) {
258
271
  const lines = [`${rows.length} registered`, ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`)];
259
272
  repositoryRegistered = rows.length > 0;
260
273
  lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
274
+ for (const owner of [...new Set(rows.map((r) => r.owner).filter((o) => typeof o === "string" && o !== ""))]) lines.push(thoughtsNote(owner));
261
275
  add("repositories", "catalyst-skills contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a tenant owner or admin", link("/settings/repositories"));
262
276
  }
263
277
  }
@@ -266,8 +280,32 @@ if (!connected) {
266
280
  // A phase runs on one of the tenant's enrolled coding accounts. With none, every step above can be
267
281
  // finished and nothing will ever start, so this part blocks the "ready" line like any other.
268
282
  // The contract says the state, its sentence, who enrolls one and on which page. An older cloud's
269
- // contract has no `codingAccounts`, and only then is the account list read and the owner assumed.
283
+ // contract has no `codingAccounts`, and only then is the owner assumed.
270
284
  let accountsNext = "enrol a coding account a phase can run on";
285
+ // The contract counts accounts by declared rotation, so one healthy account can hide another whose
286
+ // credential is dead. When it says `enrolled`, each account is read as well. An account needs a new
287
+ // 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"]);
290
+ const CREDENTIAL_FAILURE_STREAK = 3;
291
+ // 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.
293
+ const isRetirable = (a) => a?.renewalStatus === "canceled" || a?.status === "ended" || a?.declaredState === "ended";
294
+ const credentialProblem = (a) => {
295
+ if (isRetirable(a)) return null;
296
+ if (a?.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
297
+ if (a?.status === "expired-or-revoked") return "expired or revoked";
298
+ if (CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) && typeof a?.pollFailureCount === "number" && a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK) {
299
+ return `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}`;
300
+ }
301
+ return null;
302
+ };
303
+ const readAccountRows = () => {
304
+ const acc = runCli(["accounts", "--json"]);
305
+ const doc = tryJson(acc.stdout);
306
+ const rows = Array.isArray(doc) ? doc : Array.isArray(doc?.accounts) ? doc.accounts : null;
307
+ return { rows, error: rows === null ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output" : null };
308
+ };
271
309
  if (!connected) {
272
310
  add("coding accounts", "codingAccounts in catalyst-skills contract", "unreadable", ["not readable until this machine is connected"], null, null);
273
311
  } else {
@@ -279,8 +317,35 @@ if (!connected) {
279
317
  const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
280
318
  const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
281
319
  const where = typeof ca.page === "string" ? link(ca.page) : null;
282
- if (ca.state === "enrolled") {
283
- add("coding accounts", INSTRUMENT, "ok", [line, `${ca.activeCount ?? "?"} active`]);
320
+ if (ca.state === "enrolled" || ca.state === "needs_credential") {
321
+ const DETAIL = `${INSTRUMENT}, and catalyst-skills accounts`;
322
+ const head = [line, `${ca.activeCount ?? "?"} active`];
323
+ const { rows, error } = readAccountRows();
324
+ const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
325
+ const retirable = rows === null ? [] : rows.filter(isRetirable);
326
+ 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.`];
328
+ if (rows === null) {
329
+ accountsNext = "read the coding accounts again; each account's credential could not be checked";
330
+ 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);
331
+ } else if (dead.length > 0) {
332
+ const name = (a) => `${a.provider ?? "unknown provider"} account ${a.accountSlot ?? "?"}${who(a) ? ` (${who(a)})` : ""}`;
333
+ const more = dead.length > 1 ? ` (and ${dead.length - 1} more)` : "";
334
+ 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.`;
335
+ add(
336
+ "coding accounts",
337
+ DETAIL,
338
+ "unfinished",
339
+ [...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],
340
+ owner,
341
+ where,
342
+ );
343
+ } else if (ca.state === "needs_credential") {
344
+ accountsNext = "replace the credential of the coding account the page marks; do not enroll another account";
345
+ 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);
346
+ } else {
347
+ add("coding accounts", DETAIL, "ok", [...head, `${rows.length} checked, none needs a new credential`, ...retireLine]);
348
+ }
284
349
  } else if (ca.state === "inactive") {
285
350
  accountsNext = "reactivate a coding account that is out of rotation; do not enroll another one";
286
351
  add("coding accounts", INSTRUMENT, "unfinished", [line, "Every account is out of rotation. Reactivate one. Do not enroll another account."], owner, where);
@@ -292,12 +357,10 @@ if (!connected) {
292
357
  add("coding accounts", INSTRUMENT, "unreadable", [line, "This is not a missing account. Do not enroll one on this reading. Run this again later."], null, null);
293
358
  }
294
359
  } else if (older) {
295
- const acc = runCli(["accounts", "--json"]);
296
- const doc = tryJson(acc.stdout);
297
- const rows = Array.isArray(doc) ? doc : Array.isArray(doc?.accounts) ? doc.accounts : null;
360
+ const { rows, error } = readAccountRows();
298
361
  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";
299
362
  if (rows === null) {
300
- add("coding accounts", "catalyst-skills accounts", "unreadable", [olderLine, `coding accounts could not be read (${(acc.stderr || acc.stdout).trim().split("\n")[0] || "no output"})`], null, null);
363
+ add("coding accounts", "catalyst-skills accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
301
364
  } else {
302
365
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
303
366
  const usable = rows.filter((a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true);
@@ -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.1 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — 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.1 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — 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.1 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — written in this repository for customer tenants -->
8
8
 
9
9
  # How Catalyst works
10
10
 
@@ -24,7 +24,7 @@ The harness is a property of the catalog entry, never a routing input. Which pro
24
24
  - **Declared**: `active` or `disabled`. The operator sets it.
25
25
  - **Observed**: `healthy`, `degraded` or `unknown`. The poller sets it from what the provider reports.
26
26
 
27
- Two more lifecycle facts sit beside them: **quarantined** (system-set, on a credential conflict or an authentication mismatch; sticky, only an operator clears it) and **revoked** (operator-set).
27
+ Two more lifecycle facts sit beside them: **quarantined** (system-set, on a credential conflict or an authentication mismatch; Replace credential on the account's page clears it, and a tenant owner or admin can do that, so an operator is not required) and **revoked** (operator-set).
28
28
 
29
29
  ## Windows, walls, headroom
30
30
 
@@ -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.1 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — 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.1 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — 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.1 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — 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.1 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.3 — written in this repository for customer tenants -->
8
8
 
9
9
  # What's happening
10
10