@catalyst-cloud/cli 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/dist/args.js +3 -2
  4. package/dist/contract.js +8 -1
  5. package/dist/execution.js +1 -1
  6. package/dist/oauth.js +50 -25
  7. package/dist/skill-shape.js +4 -0
  8. package/dist/write.js +9 -0
  9. package/package.json +2 -2
  10. package/skills/catalyst-github/SKILL.md +1 -1
  11. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  12. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  13. package/skills/catalyst-linear/SKILL.md +2 -2
  14. package/skills/catalyst-linear/references/writing-to-linear.md +2 -2
  15. package/skills/catalyst-linear/scripts/create-ticket.mjs +14 -3
  16. package/skills/catalyst-onboard/SKILL.md +4 -1
  17. package/skills/catalyst-onboard/references/declaring-a-repository.md +26 -5
  18. package/skills/catalyst-onboard/references/replacing-a-credential.md +47 -0
  19. package/skills/catalyst-onboard/references/the-one-path.md +7 -9
  20. package/skills/catalyst-onboard/references/what-a-phase-needs.md +2 -2
  21. package/skills/catalyst-onboard/references/what-the-browser-owns.md +3 -2
  22. package/skills/catalyst-onboard/scripts/where-am-i.mjs +64 -8
  23. package/skills/catalyst-setup/SKILL.md +1 -1
  24. package/skills/catalyst-setup/references/what-each-check-means.md +1 -1
  25. package/skills/connect-me/SKILL.md +1 -1
  26. package/skills/how-catalyst-works/SKILL.md +1 -1
  27. package/skills/how-catalyst-works/references/coding-accounts.md +1 -1
  28. package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
  29. package/skills/run-this-project/SKILL.md +1 -1
  30. package/skills/unstick/SKILL.md +1 -1
  31. package/skills/what-needs-me/SKILL.md +1 -1
  32. package/skills/whats-happening/SKILL.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2
4
+
5
+ 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.
6
+
7
+ ## 0.9.1
8
+
9
+ 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.
10
+
11
+ A ticket filed through the catalyst-linear skill now carries a description: `catalyst write create` takes `--description <text>` or `--stdin`, where before it could file only a title. The skill content never asks for a personal Linear token or a direct Linear API call.
12
+
3
13
  ## 0.9.0
4
14
 
5
15
  Onboarding now guides your own Linear and GitHub consent. `catalyst connections personal <linear|github> start` opens the provider's consent page in your browser and `status` confirms the grant landed; the tenant's own Linear connection and GitHub App install stay separate, and personal GitHub waits until the App is installed and a repository is registered.
package/README.md CHANGED
@@ -163,13 +163,13 @@ Some of these only read; the ones that write anything — or that drive you thro
163
163
 
164
164
  ## What a key cannot see yet
165
165
 
166
- Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst-skills explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst-skills accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst-skills release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst-skills environment` reads the tenant-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst-skills secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst-skills secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `catalyst.env.json`, which `catalyst-setup` already reports on, is a separate thing. One thing it cannot do, and the skills say so by name rather than guess:
166
+ Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst-skills explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst-skills accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst-skills release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst-skills environment` reads the tenant-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst-skills secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst-skills secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-setup` already reports on, is a separate thing. One thing it cannot do, and the skills say so by name rather than guess:
167
167
 
168
168
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
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/args.js CHANGED
@@ -74,7 +74,7 @@ export const FLAG_TABLES = {
74
74
  },
75
75
  write: {
76
76
  body: { value: true, help: "comment: the body text" },
77
- stdin: { value: false, help: "comment: read the body from stdin" },
77
+ stdin: { value: false, help: "comment: read the body from stdin; create: read the description from stdin" },
78
78
  parent: { value: true, help: "comment: reply under this comment id" },
79
79
  bookkeeping: { value: false, help: "comment: prefix the contract's bookkeeping marker" },
80
80
  "as-user": { value: false, help: "post with the personal identity instead of the app actor" },
@@ -85,6 +85,7 @@ export const FLAG_TABLES = {
85
85
  remove: { value: true, repeat: true, help: "label: label name or id to remove (repeatable)" },
86
86
  team: { value: true, help: "create: team key" },
87
87
  title: { value: true, help: "create/attachment/session: title" },
88
+ description: { value: true, help: "create: the ticket description (markdown)" },
88
89
  label: { value: true, repeat: true, help: "create: label name or id (repeatable)" },
89
90
  priority: { value: true, help: "create: Linear priority 0-4" },
90
91
  comment: { value: true, help: "reaction: react to this comment id instead of the ticket" },
@@ -152,7 +153,7 @@ export const VERB_USAGE = {
152
153
  running: "running [--ticket T --phase P] [--json]",
153
154
  queue: "queue [--team K] [--json]",
154
155
  watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
155
- write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
156
+ write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--description|--stdin] [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
156
157
  ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
157
158
  ready: "ready [--json] [--offline]",
158
159
  accounts: "accounts [--json]",
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,7 +312,7 @@ 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",
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
  };
@@ -27,6 +27,10 @@ export const FORBIDDEN_CONTENT = [
27
27
  { name: "an internal ticket id", re: /\bC[TL]C-\d+\b/ },
28
28
  { name: "a Linear CLI name (linearis)", re: /linearis/i },
29
29
  { name: "the internal replica tool name (catalyst-replica)", re: /catalyst-replica/ },
30
+ // A tenant workflow writes through the tenant's agent proxy as the app actor. A personal Linear
31
+ // token, or a call straight to Linear's API, is the path these skills exist to replace.
32
+ { name: "a personal Linear API token (LINEAR_API_TOKEN / LINEAR_API_KEY)", re: /LINEAR_API_(?:TOKEN|KEY)/ },
33
+ { name: "Linear's API host (api.linear.app)", re: /api\.linear\.app/ },
30
34
  ];
31
35
  function walk(dir) {
32
36
  const out = [];
package/dist/write.js CHANGED
@@ -149,12 +149,21 @@ export async function cmdWrite(args, ctx, deps = {}) {
149
149
  const title = flagString(args, "title");
150
150
  if (!teamKey || !title)
151
151
  throw new UsageError("write create needs --team <key> and --title <text>");
152
+ let description = flagString(args, "description");
153
+ if (flagBool(args, "stdin")) {
154
+ if (description !== undefined)
155
+ throw new UsageError("write create takes --description <text> or --stdin, not both");
156
+ description = (await (deps.readStdin ?? readStdin)()).trimEnd();
157
+ if (!description)
158
+ throw new UsageError("write create --stdin read an empty description");
159
+ }
152
160
  const team = teamByKey(doc, teamKey);
153
161
  const labels = flagList(args, "label").map((n) => labelId(team, n));
154
162
  const priority = flagString(args, "priority") !== undefined ? flagInt(args, "priority", 0) : undefined;
155
163
  result = await postAgent(api, doc, "issue-create", {
156
164
  teamId: team.id,
157
165
  title,
166
+ ...(description ? { description } : {}),
158
167
  ...(labels.length ? { labelIds: labels } : {}),
159
168
  ...(priority !== undefined ? { priority } : {}),
160
169
  ...(asUser ? { createAsUser: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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.0 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
9
9
 
10
10
  # Catalyst Linear
11
11
 
@@ -20,7 +20,7 @@ Run each with `--help` first; scripts are executed, never read.
20
20
  - `node scripts/comment.mjs <ticket> --body <text> [--parent <id>] [--bookkeeping]` — a comment as the app actor; a machine record takes `--bookkeeping`.
21
21
  - `node scripts/move.mjs <ticket> --slot <slot>` — a card move by slot (`--state-type backlog` parks).
22
22
  - `node scripts/label.mjs <ticket> --add <name> --remove <name>` — labels, resolved through the contract.
23
- - `node scripts/create-ticket.mjs --team <key> --title <text>` — a new ticket; cite its identifier only after it prints.
23
+ - `node scripts/create-ticket.mjs --team <key> --title <text> [--description <text> | --stdin]` — a new ticket; cite its identifier only after it prints.
24
24
 
25
25
  Exit codes: 0 done, 1 not found or a usage error, 2 this machine is not connected or the write was refused (budget spent, slot unmapped, label absent; the one line printed says which).
26
26
 
@@ -9,7 +9,7 @@ This reference restates the write mechanism. The live values it depends on — t
9
9
  | a comment, threaded or top-level | `node scripts/comment.mjs <ticket> --body <text> [--parent <commentId>] [--bookkeeping]` |
10
10
  | a card move | `node scripts/move.mjs <ticket> --slot <slot>` or `--state-type backlog` |
11
11
  | a label on or off | `node scripts/label.mjs <ticket> --add <name> --remove <name>` |
12
- | a new ticket | `node scripts/create-ticket.mjs --team <key> --title <text>` |
12
+ | a new ticket | `node scripts/create-ticket.mjs --team <key> --title <text> [--description <text> \| --stdin]` |
13
13
  | a decision for a human | not here: the `what-needs-me` skill raises an ask with options, a default and what it blocks |
14
14
 
15
15
  Each script wraps one `catalyst-skills write` verb, which posts to the route the contract names, as the tenant's app actor, with the personal key this machine connected with — so the write carries the person's identity for attribution, and an ask names them. No script composes a URL, and none needs a Linear credential of its own.
@@ -36,7 +36,7 @@ Two labels are a human's, not yours: the release label (which frees a ticket the
36
36
 
37
37
  ## A new ticket takes the team key
38
38
 
39
- `create-ticket.mjs --team <key>` names the team by the prefix its identifiers carry; the CLI resolves the team id from the contract and the cloud fences the write to your tenant on the team, not only on the credential. Cite the identifier only after the script prints it; a guessed number is usually a real, unrelated ticket. Do not file a question for a human this way: a ticket whose text reads as a decision request is held out of dispatch by shape until someone releases it, and it would never reach the human's inbox with what it blocks.
39
+ `create-ticket.mjs --team <key>` names the team by the prefix its identifiers carry; the CLI resolves the team id from the contract and the cloud fences the write to your tenant on the team, not only on the credential. A ticket's body goes in `--description`, or on stdin with `--stdin` when it runs to several lines; a title with no body gives whoever picks the ticket up nothing to act on. Cite the identifier only after the script prints it; a guessed number is usually a real, unrelated ticket. Do not file a question for a human this way: a ticket whose text reads as a decision request is held out of dispatch by shape until someone releases it, and it would never reach the human's inbox with what it blocks.
40
40
 
41
41
  ## What is never offered
42
42
 
@@ -2,14 +2,17 @@
2
2
  // create-ticket.mjs — file a new ticket on a team as the app actor. The team is named by its key;
3
3
  // the CLI resolves the team id from the contract. A decision for a human is NOT a ticket filed here:
4
4
  // that is an ask, raised through the what-needs-me skill so it carries options, a default and blocks.
5
+ import { readFileSync } from "node:fs";
5
6
  import { exitOnFailure, parseFlags, parseJson, relayStderr, runCli, usage, wantsHelp } from "./lib/cli.mjs";
6
7
 
7
- const HELP = `Usage: node scripts/create-ticket.mjs --team <key> --title <text> [--label <name|id>]... [--priority <0-4>] [--as-user] [--json]
8
+ const HELP = `Usage: node scripts/create-ticket.mjs --team <key> --title <text> [--description <text> | --stdin] [--label <name|id>]... [--priority <0-4>] [--as-user] [--json]
8
9
 
9
10
  Creates one ticket. Wraps: catalyst-skills write create. Spends one unit of the daily write budget.
10
11
 
11
12
  --team <key> the team key (the prefix of its ticket identifiers)
12
13
  --title <text> the ticket title
14
+ --description <text> the ticket description, in markdown
15
+ --stdin read the description from stdin instead (for a multi-line body)
13
16
  --label <name|id> a label to apply (repeatable); Catalyst label names resolve via the contract
14
17
  --priority <0-4> Linear priority: 0 none, 1 urgent, 2 high, 3 medium, 4 low
15
18
  --as-user create with the personal identity instead of the app actor (rare)
@@ -26,14 +29,22 @@ if (wantsHelp(argv)) {
26
29
  console.log(HELP);
27
30
  process.exit(0);
28
31
  }
29
- const { flags } = parseFlags(argv, { bool: ["as-user", "json"], value: ["team", "title", "priority"], repeat: ["label"] });
32
+ const { flags } = parseFlags(argv, { bool: ["stdin", "as-user", "json"], value: ["team", "title", "description", "priority"], repeat: ["label"] });
30
33
  if (!flags.team || !flags.title) usage("create-ticket needs --team <key> and --title <text> (see --help)");
34
+ if (flags.description && flags.stdin) usage("give --description or --stdin, not both");
31
35
 
32
36
  const args = ["write", "create", "--team", flags.team, "--title", flags.title, "--json"];
37
+ if (flags.description) args.push("--description", flags.description);
38
+ let stdin;
39
+ if (flags.stdin) {
40
+ stdin = readFileSync(0, "utf8");
41
+ if (!stdin.trim()) usage("stdin was empty");
42
+ args.push("--stdin");
43
+ }
33
44
  for (const l of flags.label ?? []) args.push("--label", l);
34
45
  if (flags.priority !== undefined) args.push("--priority", flags.priority);
35
46
  if (flags["as-user"]) args.push("--as-user");
36
- const r = runCli(args);
47
+ const r = runCli(args, { stdin });
37
48
  exitOnFailure(r);
38
49
  relayStderr(r);
39
50
  const result = parseJson(r.stdout);
@@ -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.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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
 
@@ -8,15 +8,36 @@ The cloud containers build and test the repository. They need the names of the v
8
8
  2. For each name, record the file it came from: a `.env.example` (never `.env`, which holds live values), a CI secret in a workflow's `env:` block, or a platform binding. A name you cannot tie to a file is a guess. Say so instead of listing it.
9
9
  3. Mark whether the container needs it to build and test, or only to deploy. Containers build and test. They do not deploy.
10
10
  4. Leave platform bindings and deploy-only CI secrets out by default. The platform supplies a binding at run time, and no build or test reads a deploy-only secret. Include one only when the person says the build needs it, and say why.
11
- 5. Write the names into `catalyst.env.json` at the repository root and open a pull request. Use that exact name and place; the cloud reads no other file. It reads the file once the pull request merges to the default branch. A tenant owner or admin then approves it at Settings → Repositories → the repository → Environment, and the person enters the values on that page.
11
+ 5. Write the names into `.catalyst/catalyst.toml` and open a pull request. Use that exact path. The cloud reads no other file, and an older `catalyst.env.json` is ignored. Once the pull request merges, each push to the default branch that touches the file makes the cloud read it and propose it as a new revision. A tenant owner or admin opens Settings → Repositories → the repository → Environment → Setup declaration and clicks Approve this revision. The person enters the values on the same page's Environment variables and Secrets tabs.
12
12
 
13
13
  ## The file's shape
14
14
 
15
- `"version": 1` plus eight arrays, each present even when empty: `toolchains`, `systemPackages`, `setup`, `verify`, `services`, `environment`, `agentAssets`, `provenance`. The repository's Environment page in the app shows a complete, valid example. Start from it rather than from memory.
15
+ `[project]` with `linear_team` is required. Every other section is optional. The names a build needs go under `[environment]`:
16
16
 
17
- - `setup` and `verify` carry this repository's own install and test commands. `command` is an argument list, not a shell string.
18
- - Each name under `environment` says whether it is `required`. Add `"secret": true` beside a secret.
19
- - Each `provenanceIds` entry points at a record under `provenance` that names the file the name came from.
17
+ ```toml
18
+ #:schema https://staging.catalystcloud.dev/schemas/catalyst.schema.json
19
+
20
+ [project]
21
+ linear_team = "ENG"
22
+
23
+ [[environment.variables]]
24
+ name = "DATABASE_URL"
25
+ required = true
26
+
27
+ [[environment.variables]]
28
+ name = "STRIPE_API_KEY"
29
+ required = true
30
+ secret = true
31
+
32
+ [[environment.setup]]
33
+ name = "install"
34
+ run = ["npm", "ci"]
35
+ ```
36
+
37
+ - `linear_team` is the key of the Linear team this repository's work belongs to.
38
+ - Each `[[environment.variables]]` entry names one variable and says whether it is `required`. Add `secret = true` for a secret. Names only: a value never goes in this file.
39
+ - `[[environment.setup]]` steps carry this repository's own install commands. `run` is an argument list, not a shell string, and `name` is kebab-case. `verify` steps have the same shape and carry the test commands. `toolchains`, `system_packages`, `services` and the other `[environment]` tables are optional; the schema lists their fields.
40
+ - The `#:schema` line lets an editor check the file as you type.
20
41
 
21
42
  ## After the merge
22
43
 
@@ -0,0 +1,47 @@
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". The person runs `claude setup-token` in their terminal, pastes the token it prints into that field, and presses Replace credential. Then re-run the script.
38
+
39
+ ## Any other provider
40
+
41
+ Replace its credential on the same page, with the credential that page asks for.
42
+
43
+ ## What Replace credential does
44
+
45
+ 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.
46
+
47
+ 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.
@@ -118,16 +118,15 @@ Add `--approve` to approve exactly the revision that propose just returned, whic
118
118
 
119
119
  **Owner:** you can read it from any active seat; proposing and approving need an admin or owner seat, and the cloud refuses with that sentence if the person does not have one — read the refusal to them rather than retrying.
120
120
 
121
- ⛔ **Values never pass through you.** The declaration carries the *names* a build needs. The values are entered by the person, once, in the app, and nothing you run ever sees them. Say that plainly; a person asked for a secret by an agent is right to be suspicious. The names one repository needs go in its own committed `catalyst.env.json`. See `references/declaring-a-repository.md`.
121
+ ⛔ **Values never pass through you.** The declaration carries the *names* a build needs. The values are entered by the person, once, in the app, and nothing you run ever sees them. Say that plainly; a person asked for a secret by an agent is right to be suspicious. The names one repository needs go in its own committed `.catalyst/catalyst.toml`. See `references/declaring-a-repository.md`.
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
 
@@ -26,7 +27,7 @@ These are settings pages today because the routes behind them take a browser ses
26
27
  | checking a project's readiness, or re-checking it | the same page | "I can read the verdict your tenant last stored, from the contract. Asking for a fresh check is on that page." |
27
28
  | mapping stages, or adopting the workflow | the same page, per project | "Pick one project, then **Map my stages** — or **Adopt the Catalyst workflow** if you want Catalyst's stages created for you." |
28
29
  | registering a repository | `<their cloud>/settings/repositories` | "Add the repository here, and attach it to the project you just mapped, in the same form." |
29
- | approving the environment **for one repository**, and entering its values | that repository's environment section under `<their cloud>/settings/repositories` | "The names come from `catalyst.env.json` in the repository, which I can write with you. Approving it and entering the values happen here, once, by you. Nothing I run ever sees a value." (Account-wide names are **not** on this list: `catalyst-skills environment` does those.) |
30
+ | approving the environment **for one repository**, and entering its values | that repository's Environment page under `<their cloud>/settings/repositories`: the Setup declaration tab's Approve this revision button approves, and the Environment variables and Secrets tabs take the values | "The names come from `.catalyst/catalyst.toml` in the repository, which I can write with you. Approving it and entering the values happen here, once, by you. Nothing I run ever sees a value." (Account-wide names are **not** on this list: `catalyst-skills environment` does those.) |
30
31
 
31
32
  ⛔ **Do not compose a request for any of these.** A skill script never makes a request of its own; only the CLI does, and the CLI has no verb for them. If you find yourself constructing a URL, stop.
32
33
 
@@ -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,28 @@ 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
+ const credentialProblem = (a) => {
292
+ if (a?.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
293
+ if (a?.status === "expired-or-revoked") return "expired or revoked";
294
+ if (CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) && typeof a?.pollFailureCount === "number" && a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK) {
295
+ return `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}`;
296
+ }
297
+ return null;
298
+ };
299
+ const readAccountRows = () => {
300
+ const acc = runCli(["accounts", "--json"]);
301
+ const doc = tryJson(acc.stdout);
302
+ const rows = Array.isArray(doc) ? doc : Array.isArray(doc?.accounts) ? doc.accounts : null;
303
+ return { rows, error: rows === null ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output" : null };
304
+ };
271
305
  if (!connected) {
272
306
  add("coding accounts", "codingAccounts in catalyst-skills contract", "unreadable", ["not readable until this machine is connected"], null, null);
273
307
  } else {
@@ -279,8 +313,32 @@ if (!connected) {
279
313
  const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
280
314
  const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
281
315
  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`]);
316
+ if (ca.state === "enrolled" || ca.state === "needs_credential") {
317
+ const DETAIL = `${INSTRUMENT}, and catalyst-skills accounts`;
318
+ const head = [line, `${ca.activeCount ?? "?"} active`];
319
+ const { rows, error } = readAccountRows();
320
+ const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
321
+ if (rows === null) {
322
+ accountsNext = "read the coding accounts again; each account's credential could not be checked";
323
+ 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
+ } else if (dead.length > 0) {
325
+ const name = (a) => `${a.provider ?? "unknown provider"} account ${a.accountSlot ?? "?"}`;
326
+ const more = dead.length > 1 ? ` (and ${dead.length - 1} more)` : "";
327
+ 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
+ add(
329
+ "coding accounts",
330
+ DETAIL,
331
+ "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."],
333
+ owner,
334
+ where,
335
+ );
336
+ } else if (ca.state === "needs_credential") {
337
+ accountsNext = "replace the credential of the coding account the page marks; do not enroll another account";
338
+ 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
+ } else {
340
+ add("coding accounts", DETAIL, "ok", [...head, `${rows.length} checked, none needs a new credential`]);
341
+ }
284
342
  } else if (ca.state === "inactive") {
285
343
  accountsNext = "reactivate a coding account that is out of rotation; do not enroll another one";
286
344
  add("coding accounts", INSTRUMENT, "unfinished", [line, "Every account is out of rotation. Reactivate one. Do not enroll another account."], owner, where);
@@ -292,12 +350,10 @@ if (!connected) {
292
350
  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
351
  }
294
352
  } 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;
353
+ const { rows, error } = readAccountRows();
298
354
  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
355
  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);
356
+ add("coding accounts", "catalyst-skills accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
301
357
  } else {
302
358
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
303
359
  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.0 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
8
8
 
9
9
  # Am I set up?
10
10
 
@@ -22,7 +22,7 @@ The contract's `readinessChecks[]` is the list of record. A check that appears t
22
22
  | `writes_land` | Catalyst has written to this team successfully | "no write observed" is waiting, not failing: it clears the first time Catalyst moves a ticket. "Write refused" means Linear rejected the last write: check the connection and the team's permissions | owner or admin when refused; otherwise nobody |
23
23
  | `webhook_covers_team` | events for this team are arriving | confirmed once a repository is registered and events flow; "no delivery observed" is waiting | owner or admin, by registering the repository |
24
24
  | `hosts_current` | no connected host runs an older mapping revision | a host that is behind re-loads the mapping on its next connect; a host that did not report its revision is flagged rather than assumed current; "no host connected" is waiting | whoever runs that host |
25
- | `environment_declared` | a committed environment declaration for the team's default repository is in effect (`catalyst.env.json` at the repository root was ingested, is valid, and its latest proposal is approved) | `no_team_repo_default`: register a repository and make it the team's default; `no_environment_declaration`: commit the declaration file to that repository; `declaration_invalid` / `declaration_read_failed`: fix the file (the ingest names the error); `declaration_awaiting_approval`: an owner or admin approves the proposal in Settings → Environment | owner or admin, except committing the file, which is whoever can push to the repository |
25
+ | `environment_declared` | a committed environment declaration for the team's default repository is in effect (`.catalyst/catalyst.toml` on the default branch was ingested, is valid, and its latest proposal is approved) | `no_team_repo_default`: register a repository and make it the team's default; `no_environment_declaration`: commit `.catalyst/catalyst.toml` with its `[environment]` names to that repository's default branch; `declaration_invalid` / `declaration_read_failed`: fix the file (the ingest names the error); `declaration_awaiting_approval`: an owner or admin opens Settings → Repositories → the repository → Environment → Setup declaration and clicks Approve this revision | owner or admin, except committing the file, which is whoever can push to the repository |
26
26
  | `tools_resolvable` | every MCP server and CLI the team's approved environment declaration names can be resolved for that team's repository | `tool_reference_unresolved`: the declaration names a vault secret that does not exist — add it at the right scope or correct the declaration; `toolchain_cli_missing`: a declared CLI is not in the runner image, which is not self-service; `tool_declarations_unread` reads as `unknown` and clears itself | owner or admin, in the declaration or Settings → Environment |
27
27
  | `reviewer_required` | this team's repository can merge under its own merge policy with the reviewers configured | it fails only under a strict merge policy with no reviewer — configure one at Settings → Repositories → Code reviews, or change the repository's merge policy; work still runs, only the merge waits | owner or admin, in settings |
28
28
  | `reviewer_configured` | a code reviewer is configured for this team's repository at all, whatever the current policy requires | a *failing* `reviewer_configured` never blocks or degrades a team — with no reviewer, pull requests merge on green checks and resolved threads alone; configure one at Settings → Repositories → Code reviews. An `unknown` one (`merge_reviewer_unread`: the reviewer registry could not be read) still degrades the team, like any unknown | owner or admin, in settings; nobody is required when it fails |
@@ -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.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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.0 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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
 
@@ -30,7 +30,7 @@ The contract's `teams[].workflowMode` reports which one a team is in:
30
30
 
31
31
  `teams[].gitAutomation` is a stored consent for a feature that is not built yet (Catalyst managing a team's Linear git automations); nothing reads it, so `off` never stops work and turning it on would start none. What decides whether a team's tickets start is the mapping above: until dispatch, pr, done and canceled each point at a live stage, Catalyst starts nothing in that team, and `explain` says so by name.
32
32
 
33
- Stage mappings live in the app, at Settings → Linear teams. The cloud does not read `.catalyst/catalyst.toml` yet, so a mapping written there changes nothing today. An older `.catalyst/config.json` is not imported.
33
+ Stage mappings live in the app, at Settings → Linear teams. The cloud reads each repository's `.catalyst/catalyst.toml`, but that file has no stage-mapping section, so a mapping written there changes nothing. An older `.catalyst/config.json` is not imported.
34
34
 
35
35
  ## The state id is the authority; names are display
36
36
 
@@ -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.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — 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.0 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.2 — written in this repository for customer tenants -->
8
8
 
9
9
  # What's happening
10
10