@catalyst-cloud/cli 0.9.4 → 0.13.0

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 (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +38 -32
  3. package/dist/args.js +16 -2
  4. package/dist/ask.js +2 -2
  5. package/dist/capabilities.js +70 -0
  6. package/dist/cli.js +42 -29
  7. package/dist/config.js +1 -1
  8. package/dist/connections.js +5 -5
  9. package/dist/env/declaration-rules.js +163 -0
  10. package/dist/env/group.js +51 -0
  11. package/dist/env/inventory.js +102 -0
  12. package/dist/env/render.js +39 -0
  13. package/dist/env/scan-env-example.js +62 -0
  14. package/dist/env/scan-source.js +43 -0
  15. package/dist/env/scan-workflow.js +123 -0
  16. package/dist/env/scan-wrangler.js +120 -0
  17. package/dist/env/types.js +1 -0
  18. package/dist/env/walk.js +87 -0
  19. package/dist/env.js +76 -0
  20. package/dist/environment.js +4 -4
  21. package/dist/events.js +1 -1
  22. package/dist/execution.js +3 -2
  23. package/dist/identity.js +3 -3
  24. package/dist/oauth.js +3 -3
  25. package/dist/query.js +27 -1
  26. package/dist/ready.js +3 -3
  27. package/dist/release.js +1 -1
  28. package/dist/replica.js +9 -7
  29. package/dist/runtime-store.js +2 -2
  30. package/dist/runtime.js +1 -1
  31. package/dist/secret.js +1 -1
  32. package/dist/skills.js +1 -1
  33. package/dist/team.js +329 -0
  34. package/package.json +4 -4
  35. package/skills/catalyst-github/SKILL.md +1 -1
  36. package/skills/catalyst-linear/SKILL.md +1 -1
  37. package/skills/catalyst-onboard/SKILL.md +7 -7
  38. package/skills/catalyst-onboard/references/choosing-a-coding-account.md +2 -2
  39. package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
  40. package/skills/catalyst-onboard/references/local-sync.md +3 -3
  41. package/skills/catalyst-onboard/references/skill-sources.md +1 -1
  42. package/skills/catalyst-onboard/references/the-one-path.md +12 -10
  43. package/skills/catalyst-onboard/references/what-a-phase-needs.md +5 -5
  44. package/skills/catalyst-onboard/references/what-the-browser-owns.md +6 -9
  45. package/skills/catalyst-onboard/references/who-fixes-what.md +35 -16
  46. package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
  47. package/skills/catalyst-onboard/scripts/where-am-i.mjs +133 -54
  48. package/skills/catalyst-setup/SKILL.md +1 -1
  49. package/skills/connect-me/SKILL.md +3 -3
  50. package/skills/how-catalyst-works/SKILL.md +5 -3
  51. package/skills/how-catalyst-works/references/settings-and-where-they-live.md +20 -0
  52. package/skills/how-catalyst-works/references/what-runs-next.md +19 -1
  53. package/skills/run-this-project/SKILL.md +1 -1
  54. package/skills/unstick/SKILL.md +1 -1
  55. package/skills/what-needs-me/SKILL.md +1 -1
  56. package/skills/what-needs-me/references/reading-the-inbox.md +4 -0
  57. package/skills/what-this-repo-needs/SKILL.md +40 -0
  58. package/skills/what-this-repo-needs/agents/openai.yaml +6 -0
  59. package/skills/what-this-repo-needs/agents/portability.yaml +4 -0
  60. package/skills/what-this-repo-needs/references/what-each-group-means.md +68 -0
  61. package/skills/what-this-repo-needs/scripts/check.mjs +64 -0
  62. package/skills/what-this-repo-needs/scripts/inventory.mjs +61 -0
  63. package/skills/what-this-repo-needs/scripts/lib/cli.mjs +114 -0
  64. package/skills/what-this-repo-needs/scripts/lib/credential.mjs +29 -0
  65. package/skills/whats-happening/SKILL.md +1 -1
  66. package/skills/whats-happening/references/reading-the-board.md +1 -1
  67. package/skills/whats-happening/references/reprioritising.md +1 -1
  68. package/skills/whats-happening/references/status-reply.md +2 -0
  69. package/skills/whats-happening/references/why-is-it-stuck.md +41 -33
@@ -20,7 +20,8 @@ const NOTES = [
20
20
  "`replica status --probe --json` and `events status --probe --json` (optional local freshness),",
21
21
  "`me --json` and personal connection statuses (person), `contract --path …` for the account, the projects and the repositories,",
22
22
  "`contract --path codingAccounts` and `accounts --json` (coding accounts, and which one needs a new credential), and each",
23
- "project's hosts_current check with its fixedWhere (host), and each project's environment_declared check with its per-repository notes (repository declarations).",
23
+ "project's hosts_current check with its fixedWhere (host), and each project's environment_declared check with its per-repository notes (repository declarations). The teams read is refreshed, so a mapping just saved reads back mapped.",
24
+ "Reads `capabilities --json` once: when the installed CLI can run a step for this person's role (`team check`, `team map`), the next step is that command; otherwise the page, with who can.",
24
25
  "Also runs `gh repo view <owner>/thoughts` for each registered repository's owner, as a note: it shows the repository exists, never that the GitHub App can reach it.",
25
26
  "When the tenant serves a contract version this CLI refuses, runs `npm view @catalyst-cloud/cli version` once to say whether a newer CLI is published.",
26
27
  "Writes nothing and changes nothing. Runs before this machine is connected — that is one of the states it reports.",
@@ -45,6 +46,15 @@ let personalGithubIncomplete = false;
45
46
  let personalNext = null;
46
47
  let workspaceResolved = false;
47
48
  let repositoryRegistered = false;
49
+ // what the installed CLI can do, read once from `capabilities --json` (an older CLI has no
50
+ // such verb and reads as "nothing known"), and the person's role from `me`. Together they decide
51
+ // whether a next step is a command this person can run or a page for the person who can.
52
+ let capabilities = null;
53
+ let personRole = null;
54
+ const canManage = () => personRole === "admin" || personRole === "owner";
55
+ const capability = (verb) => (Array.isArray(capabilities) ? capabilities.find((c) => c && c.verb === verb) : undefined);
56
+ const verbAvailable = (verb) => { const c = capability(verb); return c?.availability === "available" && (c.needs === "member" || canManage()); };
57
+ const verbAdminOnly = (verb) => { const c = capability(verb); return c?.availability === "available" && c.needs === "admin" && !canManage(); };
48
58
  // `blocking` is false for a finding that is real and reportable but does not stop the next step —
49
59
  // an unmatched Linear identity is the one that matters: it must be said, and it must not become the
50
60
  // thing the person is told to go and do before they can map a project.
@@ -75,7 +85,7 @@ const readContract = (args) => {
75
85
  const status = runCli(["status"]);
76
86
  if (!status.ran) {
77
87
  process.stderr.write(`${status.stderr}\n`);
78
- process.stderr.write(`the catalyst-skills CLI could not be started. Install it, then run this again.\n`);
88
+ process.stderr.write(`the catalyst CLI could not be started. Install it, then run this again.\n`);
79
89
  process.exit(2);
80
90
  }
81
91
  const statusLines = status.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -95,7 +105,7 @@ const machineLines = connected ? statusLines : [statusLines[0] ?? "status printe
95
105
  let machineVerdict = connected ? "ok" : "unfinished";
96
106
 
97
107
  // `ready` is ONE verdict over two parts; split it by check id before anything is reported. A check
98
- // whose id begins with "team:" belongs to a project and cannot be moved from this machine.
108
+ // whose id begins with "team:" belongs to a project and needs the member's admin or owner seat.
99
109
  let projectChecks = [];
100
110
  let machineFix = null;
101
111
  let localSync;
@@ -128,33 +138,38 @@ if (connected) {
128
138
  localSync = {
129
139
  assessment: { verdict: "unknown", current: false, reason: "connect this machine before local freshness can be checked" },
130
140
  started: false,
131
- recovery: "catalyst-skills login",
141
+ recovery: "catalyst login",
132
142
  };
133
143
  }
134
144
  add(
135
145
  "machine",
136
- "catalyst-skills status, and the non-team checks of catalyst-skills ready",
146
+ "catalyst status, and the non-team checks of catalyst ready",
137
147
  machineVerdict,
138
148
  machineLines,
139
149
  "you, on this machine",
140
- // The keyless login alone: a person who holds a personal key already knows the key form.
141
- connected ? null : CONNECT_LINE.split(" (or")[0],
150
+ // The keyless login alone: a person who holds a personal key already knows the key form. A machine
151
+ // that holds the CLI is told its own command; only one with no CLI at all gets the npx line.
152
+ connected ? null : cliTarget().recorded ? "catalyst login" : CONNECT_LINE.split(" (or")[0],
142
153
  );
154
+ if (connected) {
155
+ const capDoc = tryJson(runCli(["capabilities", "--json"]).stdout);
156
+ capabilities = Array.isArray(capDoc?.capabilities) ? capDoc.capabilities : null;
157
+ }
143
158
 
144
159
  // ── person ────────────────────────────────────────────────────────────────────────────────────────
145
160
  if (!connected) {
146
- add("person", "catalyst-skills me", "unreadable", ["not readable until this machine is connected"], null, null);
161
+ add("person", "catalyst me", "unreadable", ["not readable until this machine is connected"], null, null);
147
162
  } else {
148
163
  const me = runCli(["me", "--json"]);
149
164
  const doc = tryJson(me.stdout);
150
165
  const user = doc && typeof doc.user === "object" && doc.user !== null ? doc.user : null;
151
166
  if (doc === null) {
152
- add("person", "catalyst-skills me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null).next =
153
- "run this again; catalyst-skills me could not be read, so no step after it can be named yet";
167
+ add("person", "catalyst me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null).next =
168
+ "run this again; catalyst me could not be read, so no step after it can be named yet";
154
169
  } else if (user === null) {
155
170
  add(
156
171
  "person",
157
- "catalyst-skills me",
172
+ "catalyst me",
158
173
  "unfinished",
159
174
  ["this credential names no person — it is a host credential, so nothing an agent writes will carry a name"],
160
175
  "you: connect again with your own login",
@@ -162,6 +177,7 @@ if (!connected) {
162
177
  );
163
178
  } else {
164
179
  const matched = typeof user.linearUserId === "string" && user.linearUserId !== "";
180
+ personRole = typeof user.role === "string" ? user.role : null;
165
181
  const grantLines = [];
166
182
  for (const provider of ["linear", "github"]) {
167
183
  const read = runCli(["connections", "personal", provider, "status", "--json"]);
@@ -180,17 +196,17 @@ if (!connected) {
180
196
  personalGrantIncomplete = true;
181
197
  if (provider === "linear") personalLinearIncomplete = true;
182
198
  else personalGithubIncomplete = true;
183
- grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst-skills CLI or inspect its status output"}`);
199
+ grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst CLI or inspect its status output"}`);
184
200
  if (provider === "linear") personalNext ??= "re-check your personal linear connection";
185
201
  }
186
202
  }
187
203
  add(
188
204
  "person",
189
- "catalyst-skills me, and catalyst-skills connections personal <provider> status --json",
205
+ "catalyst me, and catalyst connections personal <provider> status --json",
190
206
  matched && !personalGrantIncomplete ? "ok" : "unfinished",
191
- [`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst-skills identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.", ...grantLines],
207
+ [`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.", ...grantLines],
192
208
  personalGrantIncomplete ? "you" : matched ? null : "a workspace owner or admin",
193
- personalGrantIncomplete ? "catalyst-skills connections personal <provider> start or status" : matched ? null : link("/settings/account"),
209
+ personalGrantIncomplete ? "catalyst connections personal <provider> start or status" : matched ? null : link("/settings/account"),
194
210
  false,
195
211
  );
196
212
  }
@@ -198,13 +214,13 @@ if (!connected) {
198
214
 
199
215
  // ── account ───────────────────────────────────────────────────────────────────────────────────────
200
216
  if (!connected) {
201
- add("account", "catalyst-skills contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
217
+ add("account", "catalyst contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
202
218
  } else {
203
219
  const acct = readContract(["contract", "--path", "account", "--json"]);
204
220
  const doc = tryJson(acct.stdout);
205
221
  if (doc === null) {
206
- add("account", "catalyst-skills contract --path account", "unreadable", ["the account block could not be read — try: catalyst-skills contract --refresh"], null, null).next =
207
- "refresh the contract (catalyst-skills contract --refresh), then run this again; the account could not be read, so no step after it can be named yet";
222
+ add("account", "catalyst contract --path account", "unreadable", ["the account block could not be read — try: catalyst contract --refresh"], null, null).next =
223
+ "refresh the contract (catalyst contract --refresh), then run this again; the account could not be read, so no step after it can be named yet";
208
224
  } else {
209
225
  const workspace = typeof doc.linearWorkspaceSlug === "string" && doc.linearWorkspaceSlug !== "" ? doc.linearWorkspaceSlug : typeof doc.linearWorkspaceId === "string" && doc.linearWorkspaceId !== "" ? doc.linearWorkspaceId : null;
210
226
  workspaceResolved = workspace !== null;
@@ -216,7 +232,7 @@ if (!connected) {
216
232
  if (envDoc === null) {
217
233
  envLines.push(`environment: not readable (${(env.stderr || env.stdout).trim().split("\n")[0] ?? "no output"})`);
218
234
  } else if (envDoc.current === null || envDoc.current === undefined) {
219
- envLines.push("environment: nothing declared yet — declare it with `catalyst-skills environment propose --file <path> --approve`");
235
+ envLines.push("environment: nothing declared yet — declare it with `catalyst environment propose --file <path> --approve`");
220
236
  } else {
221
237
  envLines.push(`environment: revision ${envDoc.current.revision} (${envDoc.current.canonicalHash}), ${envDoc.isApproved ? "approved" : "NOT approved — a proposal nobody approved changes nothing"}`);
222
238
  envLines.push(envDoc.delivered ? `environment delivered to phases: revision ${envDoc.delivered.revision}` : "environment delivered to phases: nothing yet");
@@ -225,7 +241,7 @@ if (!connected) {
225
241
  }
226
242
  add(
227
243
  "account",
228
- "catalyst-skills contract --path account, and catalyst-skills environment read",
244
+ "catalyst contract --path account, and catalyst environment read",
229
245
  workspace === null ? "unfinished" : "ok",
230
246
  [
231
247
  `${doc.name ?? "(unnamed tenant)"} (${doc.slug ?? "?"})`,
@@ -241,17 +257,40 @@ if (!connected) {
241
257
 
242
258
  // The host part reads the same rows, so they are kept rather than read twice.
243
259
  let teamRows = null;
260
+ // The contract's readinessChecks metadata (severity and owner per check id), read once and shared by
261
+ // the projects, host and declarations parts. An older cloud that has none answers [].
262
+ let readinessMetaCache;
263
+ const readinessMeta = () => {
264
+ if (readinessMetaCache === undefined) {
265
+ const m = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
266
+ readinessMetaCache = Array.isArray(m) ? m : [];
267
+ }
268
+ return readinessMetaCache;
269
+ };
270
+ // Linear's own Git automation rules, by check id, as the Settings → Workflow → Git automation screen
271
+ // names them. A rule that moves a card is a conflict with the stage Catalyst manages, and no Catalyst
272
+ // key can change a Linear automation rule: the fix is in Linear, by a Linear admin.
273
+ const LINEAR_AUTOMATION_RULES = {
274
+ linear_automation_pr_open: "On PR open",
275
+ linear_automation_pr_review: "On PR review request or activity",
276
+ linear_automation_pr_ready: "On PR ready for merge",
277
+ linear_automation_pr_merge: "On PR merge",
278
+ };
279
+ let blockedProjects = [];
280
+
244
281
  // ── projects (a project is one Linear team) ───────────────────────────────────────────────────────
245
282
  if (!connected) {
246
- add("projects", "catalyst-skills contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
283
+ add("projects", "catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
247
284
  } else {
248
- const teams = readContract(["contract", "--path", "teams", "--json"]);
285
+ // The mapping write updates the cloud before the cached contract's dispatchGate projection.
286
+ // Revalidate this one read so a successful save does not appear unmapped on the next step.
287
+ const teams = readContract(["contract", "--refresh", "--path", "teams", "--json"]);
249
288
  const doc = tryJson(teams.stdout);
250
289
  const rows = Array.isArray(doc) ? doc : null;
251
290
  teamRows = rows;
252
291
  if (rows === null) {
253
- add("projects", "catalyst-skills contract --path teams", "unreadable", ["the project list could not be read — try: catalyst-skills contract --refresh"], null, null).next =
254
- "refresh the contract (catalyst-skills contract --refresh), then run this again; the project list could not be read, so no step after it can be named yet";
292
+ add("projects", "catalyst contract --path teams", "unreadable", ["the project list could not be read — try: catalyst contract --refresh"], null, null).next =
293
+ "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no step after it can be named yet";
255
294
  } else {
256
295
  const lines = [`${rows.length} mapped`];
257
296
  for (const t of rows) {
@@ -261,19 +300,42 @@ if (!connected) {
261
300
  lines.push(`${key}: ${readiness.status ?? "unknown"}${bad.length ? ` — ${bad.map((c) => `${c.id} ${c.state}`).join(", ")}` : ""}`);
262
301
  }
263
302
  for (const c of projectChecks) lines.push(`${c.ok ? "note" : "FAIL"} ${c.line}${c.who ? ` — who: ${c.who}` : ""}`);
264
- lines.push("⛔ MAPPED projects only. An empty list means nothing is mapped yet, NOT that there are no projects — the full list is on the page below.");
303
+ lines.push("MAPPED projects only. An empty list means nothing is mapped yet, NOT that there are no projects. Run catalyst team list to inspect the live list without checking readiness.");
265
304
  // A project is set up when its dispatch gate is open (its stages are mapped), or when its
266
305
  // readiness reads ready. Readiness alone kept `--next` on "map its stages" for a tenant whose
267
306
  // gates were open: readiness stays "unchecked" until someone presses Re-check, and a new team
268
307
  // stays "degraded" until a repository is attached, which is the step AFTER this one.
269
- const setUp = (t) => t.dispatchGate?.status === "open" || (t.readiness?.status ?? "unchecked") === "ready";
308
+ // ⛔ A team whose readiness reads `blocked` is NOT set up, whatever its gate says: a blocking
309
+ // check (a Linear automation that moves cards off Catalyst's stages, a missing OAuth scope) breaks
310
+ // every run in it. The step is that check's own fix, by its own owner, then a re-check.
311
+ const isBlocking = (c) => readinessMeta().length === 0 || readinessMeta().find((r) => r.id === c.id)?.severity === "blocking";
312
+ for (const t of rows.filter((r) => r.readiness?.status === "blocked")) {
313
+ const key = t.key ?? t.id ?? "(unkeyed)";
314
+ const fails = (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.state === "fail" && isBlocking(c));
315
+ const ids = fails.map((c) => c.id);
316
+ const rules = ids.filter((id) => id in LINEAR_AUTOMATION_RULES).map((id) => LINEAR_AUTOMATION_RULES[id]);
317
+ const others = ids.filter((id) => !(id in LINEAR_AUTOMATION_RULES));
318
+ const ownerRow = fails.map((c) => readinessMeta().find((r) => r.id === c.id)).find((r) => r && typeof r.fixedByLine === "string");
319
+ const owner = ownerRow ? ownerRow.fixedByLine : "a workspace owner or admin";
320
+ const how = [
321
+ rules.length > 0 ? `in Linear, open Settings → Teams → ${key} → Workflow → Git automation and set ${rules.join(", ")} to No action (no Catalyst key can change a Linear automation rule)` : null,
322
+ others.length > 0 ? `${others.join(", ")}: ${owner}` : null,
323
+ ].filter(Boolean).join("; ");
324
+ lines.push(`${key}: BLOCKED — ${ids.length > 0 ? ids.join(", ") : "a blocking check failed"}; ${how || `owner: ${owner}`}`);
325
+ blockedProjects.push({ key, action: `fix ${key}'s blocking check${ids.length === 1 ? "" : "s"} (${ids.join(", ") || "see the projects lines"}): ${how || owner}; then run catalyst team check ${key} (or press Re-check) and run this again`, owner });
326
+ }
327
+ const setUp = (t) => t.readiness?.status !== "blocked" && (t.dispatchGate?.status === "open" || (t.readiness?.status ?? "unchecked") === "ready");
270
328
  for (const t of rows) {
271
329
  if (t.dispatchGate?.status === "open" && (t.readiness?.status ?? "unchecked") === "unchecked") {
272
- lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: press Re-check on the page below to see the rest`);
330
+ lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: run catalyst team check ${t.key ?? t.id} to see the rest`);
273
331
  }
274
332
  }
275
333
  const ready = rows.length > 0 && rows.every(setUp);
276
- add("projects", "catalyst-skills contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/linear-teams"));
334
+ const mapOwner = verbAvailable("team map") ? "you, with your own login" : "a workspace owner or admin";
335
+ const mapWhere = verbAvailable("team map") ? "catalyst team list" : link("/settings/linear-teams");
336
+ const projectOwner = blockedProjects.length > 0 ? blockedProjects[0].owner : mapOwner;
337
+ const projectWhere = blockedProjects.length > 0 ? null : mapWhere;
338
+ add("projects", "catalyst contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, projectOwner, projectWhere);
277
339
  }
278
340
  }
279
341
 
@@ -290,20 +352,20 @@ function thoughtsNote(owner) {
290
352
  return `note ${repo}: gh could not see it (${why}). It does not exist, or this GitHub login cannot see it. Create or confirm it (step 5a).`;
291
353
  }
292
354
  if (!connected) {
293
- add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
355
+ add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
294
356
  } else {
295
357
  const repos = readContract(["contract", "--path", "merge.repositories", "--json"]);
296
358
  const doc = tryJson(repos.stdout);
297
359
  const rows = Array.isArray(doc) ? doc : null;
298
360
  if (rows === null) {
299
- add("repositories", "catalyst-skills contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst-skills contract --refresh"], null, null).next =
300
- "refresh the contract (catalyst-skills contract --refresh), then run this again; the repository list could not be read, so no step after it can be named yet";
361
+ add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst contract --refresh"], null, null).next =
362
+ "refresh the contract (catalyst contract --refresh), then run this again; the repository list could not be read, so no step after it can be named yet";
301
363
  } else {
302
364
  const lines = [`${rows.length} registered`, ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`)];
303
365
  repositoryRegistered = rows.length > 0;
304
366
  lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
305
367
  for (const owner of [...new Set(rows.map((r) => r.owner).filter((o) => typeof o === "string" && o !== ""))]) lines.push(thoughtsNote(owner));
306
- add("repositories", "catalyst-skills contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/repositories"));
368
+ add("repositories", "catalyst contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/repositories"));
307
369
  }
308
370
  }
309
371
 
@@ -350,18 +412,18 @@ const readAccountRows = () => {
350
412
  return { rows, error: rows === null ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output" : null };
351
413
  };
352
414
  if (!connected) {
353
- add("coding accounts", "codingAccounts in catalyst-skills contract", "unreadable", ["not readable until this machine is connected"], null, null);
415
+ add("coding accounts", "codingAccounts in catalyst contract", "unreadable", ["not readable until this machine is connected"], null, null);
354
416
  } else {
355
417
  const res = readContract(["contract", "--path", "codingAccounts", "--json"]);
356
418
  const ca = tryJson(res.stdout);
357
419
  const older = ca === null && /has nothing at/.test(res.stderr ?? "");
358
- const INSTRUMENT = "codingAccounts in catalyst-skills contract";
420
+ const INSTRUMENT = "codingAccounts in catalyst contract";
359
421
  if (ca !== null && typeof ca === "object" && typeof ca.state === "string") {
360
422
  const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
361
423
  const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
362
424
  const where = typeof ca.page === "string" ? link(ca.page) : null;
363
425
  if (ca.state === "enrolled" || ca.state === "needs_credential") {
364
- const DETAIL = `${INSTRUMENT}, and catalyst-skills accounts`;
426
+ const DETAIL = `${INSTRUMENT}, and catalyst accounts`;
365
427
  const head = [line, `${ca.activeCount ?? "?"} active`];
366
428
  const { rows, error } = readAccountRows();
367
429
  const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
@@ -405,31 +467,40 @@ if (!connected) {
405
467
  const olderLine = "this cloud is older than the bundle: its contract does not say whether a coding account is enrolled, so the account list is read instead";
406
468
  if (rows === null) {
407
469
  accountsNext = "read the coding accounts again; the account list could not be read this time";
408
- add("coding accounts", "catalyst-skills accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
470
+ add("coding accounts", "catalyst accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
409
471
  } else {
410
472
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
411
473
  const usable = rows.filter((a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true);
412
474
  const lines = [olderLine, `${rows.length} enrolled, ${usable.length} able to take work`];
413
475
  for (const a of rows) lines.push(`${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`);
414
476
  if (usable.length === 0) lines.push("no phase can start until one is enrolled and able to take work");
415
- add("coding accounts", "catalyst-skills accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/coding-accounts"));
477
+ add("coding accounts", "catalyst accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/coding-accounts"));
416
478
  }
417
479
  } else {
418
480
  accountsNext = "read the coding accounts again; the contract could not be read";
419
- add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst-skills contract --refresh`], null, null);
481
+ add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst contract --refresh`], null, null);
420
482
  }
421
483
  }
422
484
 
485
+ // the Re-check step, as a command when this person can run it, else as the page with the
486
+ // person who can. `team check` is the CLI twin of the page's Re-check button (admin or owner).
487
+ const mappedKeys = () => (teamRows ?? []).map((t) => t.key ?? t.id).filter((k) => typeof k === "string" && k !== "");
488
+ const recheckStep = () => {
489
+ const cmds = mappedKeys().map((k) => `catalyst team check ${k}`);
490
+ if (cmds.length > 0 && verbAvailable("team check")) return { action: `run ${cmds.join(" and ")}, then run this again`, owner: "you, the assistant: run it now with the person's login, without asking", where: cmds[0] };
491
+ if (cmds.length > 0 && verbAdminOnly("team check")) return { action: `a workspace owner or admin runs ${cmds.join(" and ")} (or presses Re-check on the projects page); then run this again`, owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
492
+ return { action: "press Re-check on the projects page, then run this again", owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
493
+ };
494
+
423
495
  // ── host ──────────────────────────────────────────────────────────────────────────────────────────
424
496
  // Read off the contract, never assumed: each checked project carries a hosts_current check, and the
425
497
  // contract says who owns it. The check is account-wide, so any one project's reading is the answer.
426
498
  // A tenant that runs no host of its own reads `pass` here, and then nothing is asked of anyone.
427
499
  if (!connected) {
428
- add("host", "hosts_current in catalyst-skills contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
500
+ add("host", "hosts_current in catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
429
501
  } else {
430
502
  const checks = (teamRows ?? []).flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "hosts_current").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
431
- const meta = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
432
- const row = Array.isArray(meta) ? meta.find((r) => r.id === "hosts_current") : null;
503
+ const row = readinessMeta().find((r) => r.id === "hosts_current") ?? null;
433
504
  const hc = checks.find((c) => c.state === "fail") ?? checks.find((c) => c.state === "unknown") ?? checks[0] ?? null;
434
505
  // The contract's printed line for this owner is written for a host that is behind. When no host is
435
506
  // connected there is nothing behind, so the owner is named by its id instead of by that sentence.
@@ -444,15 +515,16 @@ if (!connected) {
444
515
  const how = fw === null ? null : link(fw.page);
445
516
  const howLines = fw?.command ? [`the owner runs: ${fw.command}`] : [];
446
517
  if (hc === null) {
447
- add("host", "hosts_current in catalyst-skills contract --path teams", "unreadable", [teamRows === null ? "the project list could not be read, so the host check cannot be either" : "no project has a readiness check yet, so whether a host is connected cannot be read. Press Re-check on the projects page."], "a workspace owner or admin", link("/settings/linear-teams")).next =
518
+ const step = recheckStep();
519
+ add("host", "hosts_current in catalyst contract --path teams", "unreadable", [teamRows === null ? "the project list could not be read, so the host check cannot be either" : "no project has a readiness check yet, so whether a host is connected cannot be read."], teamRows === null ? "a workspace owner or admin" : step.owner, teamRows === null ? link("/settings/linear-teams") : step.where).next =
448
520
  teamRows === null
449
- ? "refresh the contract (catalyst-skills contract --refresh), then run this again; the project list could not be read, so the host check cannot be either"
450
- : "press Re-check on the projects page, then run this again";
521
+ ? "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so the host check cannot be either"
522
+ : step.action;
451
523
  } else if (hc.state === "pass") {
452
- add("host", "hosts_current in catalyst-skills contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
524
+ add("host", "hosts_current in catalyst contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
453
525
  } else {
454
526
  const detail = hc.reason === "no_host_connected" ? "no Catalyst host is connected" : hc.reason === "hosts_behind" ? "a connected host runs an older mapping" : hc.reason === "hosts_unreported" ? "a host is connected but has not reported what it loaded" : `state ${hc.state}`;
455
- add("host", "hosts_current in catalyst-skills contract --path teams", "unfinished", [`hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`, ...howLines], owner, how);
527
+ add("host", "hosts_current in catalyst contract --path teams", "unfinished", [`hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`, ...howLines], owner, how);
456
528
  }
457
529
  }
458
530
 
@@ -461,7 +533,7 @@ if (!connected) {
461
533
  // default repository has one in effect is the contract's `environment_declared` check; the project's
462
534
  // other repositories appear under that check's `repos`. Names only: nothing here reads a value, and
463
535
  // nothing here reads a file on this machine. A cloud that sends no such check has nothing to read.
464
- const DECL_INSTRUMENT = "environment_declared in catalyst-skills contract --path teams";
536
+ const DECL_INSTRUMENT = "environment_declared in catalyst contract --path teams";
465
537
  const DECL_REASONS = {
466
538
  no_team_repo_default: { text: "no repository is the project's default yet: register one and make it the default", who: "a workspace owner or admin", page: "/settings/repositories" },
467
539
  no_environment_declaration: { text: "no .catalyst/catalyst.toml on its default branch yet: write it with the person (names only, never a value), open a pull request, merge it", who: "the person, in the repository, with your help", page: null },
@@ -475,15 +547,15 @@ if (!connected) {
475
547
  add("repository declarations", DECL_INSTRUMENT, "unreadable", ["not readable until this machine is connected"], null, null);
476
548
  } else if (teamRows === null) {
477
549
  add("repository declarations", DECL_INSTRUMENT, "unreadable", ["the project list could not be read, so no repository declaration can be either"], null, null).next =
478
- "refresh the contract (catalyst-skills contract --refresh), then run this again; the project list could not be read, so no repository declaration can be either";
550
+ "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no repository declaration can be either";
479
551
  } else if (teamRows.length === 0) {
480
552
  add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project is mapped yet, so there is no repository whose declaration could be read"], null, null).next =
481
553
  "map a project first; a repository declaration is read per project";
482
554
  } else if (teamRows.every((t) => !Array.isArray(t.readiness?.checks) || t.readiness.checks.length === 0)) {
483
555
  // Mapped, never checked: the contract carries no checks at all until someone presses Re-check, and
484
556
  // that silence is not "nothing to declare".
485
- add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project has a readiness check yet, so whether its repository is declared cannot be read. Press Re-check on the projects page."], "a workspace owner or admin", link("/settings/linear-teams")).next =
486
- "press Re-check on the projects page, then run this again";
557
+ const step = recheckStep();
558
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project has a readiness check yet, so whether its repository is declared cannot be read."], step.owner, step.where).next = step.action;
487
559
  } else {
488
560
  const found = teamRows.flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "environment_declared").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
489
561
  const lines = [];
@@ -508,12 +580,13 @@ if (!connected) {
508
580
  else if (c.state === "fail") flag(c.team, null, c.reason);
509
581
  else {
510
582
  unknown = true;
511
- lines.push(`${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; press Re-check on the projects page`);
583
+ lines.push(`${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; a re-check is needed`);
512
584
  }
513
585
  for (const note of Array.isArray(c.repos) ? c.repos : []) if (typeof note?.repo === "string" && note.reason) flag(c.team, note.repo, note.reason);
514
586
  }
515
587
  if (unknown && verdict === "ok") {
516
- add("repository declarations", DECL_INSTRUMENT, "unreadable", lines, "a workspace owner or admin", link("/settings/linear-teams")).next = "press Re-check on the projects page, then run this again";
588
+ const step = recheckStep();
589
+ add("repository declarations", DECL_INSTRUMENT, "unreadable", lines, step.owner, step.where).next = step.action;
517
590
  } else {
518
591
  if (verdict !== "ok") lines.push("Names only: a value never passes through this script or the file. The person enters values on the repository's Environment page.");
519
592
  add("repository declarations", DECL_INSTRUMENT, verdict, lines, verdict === "ok" ? null : owner, verdict === "ok" ? null : where);
@@ -537,7 +610,13 @@ const NEXT = {
537
610
  ? "install the tenant GitHub App and register its repository before connecting your personal GitHub account"
538
611
  : "get this person's seat and Linear identity sorted",
539
612
  account: "connect the Linear integration on the Connections page (the GitHub App comes later, with the repository)",
540
- projects: "pick ONE project and map its stages (or adopt the Catalyst workflow)",
613
+ projects: blockedProjects.length > 0
614
+ ? blockedProjects.map((b) => b.action).join("; and ")
615
+ : verbAvailable("team map")
616
+ ? "pick ONE project: run catalyst team list, then catalyst team map <KEY> (or team adopt <KEY>) and approve its preview"
617
+ : verbAdminOnly("team map")
618
+ ? "a workspace owner or admin maps ONE project: catalyst team map <KEY>, or Map my stages on the projects page"
619
+ : "pick ONE project and map its stages (or adopt the Catalyst workflow)",
541
620
  repositories: "install the GitHub App on the Connections page, granting it the repository you want worked; registering that repository on the Repositories page, attached to the project, is the step right after",
542
621
  "coding accounts": accountsNext,
543
622
  "repository declarations": declNext,
@@ -581,7 +660,7 @@ function versionAdvice() {
581
660
  const installed = semver(field("Bundle"));
582
661
  const npm = spawnSync("npm", ["view", "@catalyst-cloud/cli", "version"], { encoding: "utf8", timeout: 10_000, shell: process.platform === "win32" });
583
662
  const latest = npm.error || npm.status !== 0 ? null : semver(npm.stdout);
584
- // The installer is the update path, not `catalyst-skills install`. Its command is on the app's
663
+ // The installer is the update path, not `catalyst install`. Its command is on the app's
585
664
  // setup page; this names the script it fetches rather than composing the request here.
586
665
  const reinstall = `re-run the install command from the app's setup page (it installs from ${link("/install.sh")})`;
587
666
  if (installed === null || latest === null) {
@@ -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.4 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — 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.4 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
9
9
 
10
10
  # Connect me
11
11
 
@@ -34,7 +34,7 @@ CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
34
34
  ## What you do
35
35
 
36
36
  1. Prefer keyless: run `catalyst-skills login` with no key and have the person approve the code in their browser (or from a phone). Nothing to mint, nothing to paste, nothing to keep out of the transcript. A **personal key** is the fallback for a script or unattended shell — they mint it at Settings → API keys (every active member can; no admin is needed) and hand it to the login command via `CATALYST_CLOUD_TOKEN`; it is shown once, so never put it in a file, a ticket, or this conversation beyond that one command. Either way, do not ask an admin for the tenant's **account key** (`ctc_acct_`, Settings → Account keys): that is a host credential — runners, daemons, the host replica — and a person connected with it has no name on anything their agent writes, and "what needs me" cannot mean them. If they connect with one anyway, login says so on stderr and still works.
37
- 2. Run the login command. Its output names the tenant (`Connected to <name> (<slug>)`), the person (`Connected as <label> (<role>)`), the config path, and the cached contract version. If the person line says their Linear identity is not matched yet, tell them: an admin matches it in Settings → Members, and until then "what needs me" shows everyone's asks.
37
+ 2. Run the login command. Its output names the tenant (`Connected to <name> (<slug>)`), the person (`Connected as <label> (<role>)`), the config path, and the cached contract version. If the person line says their Linear identity is not matched yet, tell them: an admin matches it in Settings → Members, and until then "what needs me" shows everyone's asks — connecting Linear personally does not match the identity.
38
38
  3. Verify with `node scripts/verify-connection.mjs`: one line each for the tenant, the contract version, and the replica; exit 1 when the machine is not connected.
39
39
  4. Run `catalyst-skills ready` and read the verdict to them.
40
40
  5. Do not offer the replica. It is optional and off by default for large projects while the snapshot path is made safe, and every read works through the API without it. Start it only if the person asks for local SQL, and then load `references/keeping-the-replica-running.md`.
@@ -54,7 +54,7 @@ Every skill session opens with these, in this order, before doing anything else:
54
54
  | when | read |
55
55
  | -- | -- |
56
56
  | the person wants the replica writer to survive a reboot, or asks what it stores and whether anything needs cleaning | `references/keeping-the-replica-running.md` |
57
- | the full readiness vector and what each check means | the `catalyst-setup` skill |
57
+ | the full readiness vector — the check, its severity, and who can click what | the `catalyst-setup` skill |
58
58
 
59
59
  ## Rules
60
60
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: how-catalyst-works
3
3
  description: >-
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.
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, the coding-account model, and which settings screen governs which rule. Use when a person asks "how does this work?", "why did it do that?", "why is this stuck?", "what runs next?", "how does it prioritise?" or "what does this setting do, where is it set?". 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.4 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
8
8
 
9
9
  # How Catalyst works
10
10
 
@@ -30,12 +30,14 @@ Exit codes: 0 answered, 1 not found or a usage error, 2 this machine is not conn
30
30
  | "what are the phases, what does each produce, when is a ticket Done?" | `references/the-ladder.md` |
31
31
  | "which column is which, why does nothing dispatch, what is a slot?" | `references/stages-and-mapping.md` |
32
32
  | a phase FAILED, a card went to Remediate, a ticket is parked or on hold | `references/when-a-phase-fails.md` |
33
- | "what runs next, why not this one, what does this exclusion reason mean?" | `references/what-runs-next.md` |
33
+ | "what runs next, why not this one, what does this exclusion reason mean?", "why does nothing start while nothing runs?" (the WIP limit) | `references/what-runs-next.md` |
34
34
  | "why is nothing running", walls, quarantine, which provider ran a phase | `references/coding-accounts.md` |
35
+ | "what does this setting do, where is it set, who can change it?" | `references/settings-and-where-they-live.md` |
35
36
 
36
37
  ## Rules
37
38
 
38
39
  - **Print, never recall.** A stage name, label, threshold or route in your answer must have come from a script's output in this session.
40
+ - **A setting has a page.** Name the screen, its route and the rule from `references/settings-and-where-they-live.md`; never answer a settings question from memory, and never invent a route.
39
41
  - **A reason is a layer.** Translate an exclusion reason through `references/what-runs-next.md`; name what releases it and who can do that (a clock, a comment, a push, an operator).
40
42
  - **Unknown is not absent.** An `unknown` verdict from the explainer means the cloud could not look; report it as inconclusive.
41
43
  - **System causes are one alert.** A provider outage, the runner image breaker or a paused repository holds many tickets for one reason; never escalate it ticket by ticket.
@@ -0,0 +1,20 @@
1
+ # Settings and where they live
2
+
3
+ This reference restates rules that do not vary per tenant: what a setting does, the screen it lives on, and who can change it. Every live value — whether a connection is up, which repositories are registered, the current merge policy — comes from the contract or a script; every route below is prefixed with `<their cloud>` because the host is the tenant's own, never a literal you invent.
4
+
5
+ | what | where it is set | the rule | who can set it |
6
+ | -- | -- | -- | -- |
7
+ | **Connecting Linear** | `<their cloud>/settings/connections` | Needs an admin or owner browser session; the tenant comes from that session, never from a URL parameter. | owner or admin |
8
+ | **One workspace, one account** | `<their cloud>/settings/connections` | A Linear workspace binds to exactly one account and an account to exactly one workspace; a second tenant for the same workspace is refused and stores nothing. That is a decision to raise, not a retry. | owner or admin |
9
+ | **Installing the GitHub App** | `<their cloud>/settings/connections`, but the admin's own personal GitHub grant comes first at `<their cloud>/settings/profile` | A refusal naming a missing GitHub identity, or an installation that is not yours, means "grant your personal GitHub first", never "the App is broken". | owner or admin |
10
+ | **Uninstalling is safe either way** | `<their cloud>/settings/connections` | Removing the App on GitHub self-heals the registry; unlinking in Catalyst uninstalls upstream best-effort. Neither needs a support ticket. | owner or admin |
11
+ | **One team, one repository** | `<their cloud>/settings/repositories` | One Linear team maps to at most one repository per account, and one GitHub repository to at most one, case-insensitively. | owner or admin |
12
+ | **Neither half can be re-pointed** | `<their cloud>/settings/repositories` | A repository is exactly one team plus one GitHub repo; the only editable field is its name. A monorepo across two teams, or one team across two repos, cannot be modelled today — do not promise it. | owner or admin |
13
+ | **An archived repository still holds both keys** | `<their cloud>/settings/repositories` | Re-registering the same team-plus-repo pair is refused until the archived entry is restored. | owner or admin |
14
+ | **Merge policy** | `<their cloud>/settings/repositories/$repoId/merging` | The effective policy and where it came from are served on the contract's `merge` block; this is the page that changes it (`catalyst-github`'s mergeability read never sets it). | owner or admin |
15
+ | **Reviewers** | `<their cloud>/settings/repositories/$repoId/code-reviews` | "Post review requests as" names one connected member; a member cannot volunteer or refuse. A strict merge policy with no reviewer configured never earns the merge label — configure a reviewer, or change the policy on the merging page. | owner or admin |
16
+ | **The merge queue is assumed, and this bundle cannot see it** | (no settings page; the repository's own merge automation — a Mergify configuration, GitHub's native merge queue, or whatever else that repository runs) | The merge phase never merges: its one write is the ready label. Without a queue consuming that label the label is still the cloud's attestation, and the merge itself is whatever the repository's own setup does with it. Nothing here detects or repairs that, and nothing here knows which queue a tenant runs; say that by name rather than guessing, and never name a vendor the tenant has not named first. | whoever administers the GitHub repository |
17
+ | **The runner cap** | `<their cloud>/settings/repositories` | Default 20 running phases per repository; a paused repository resolves to zero without losing the stored value. The tenant page is display-only — lowering it is an operator action. See `references/what-runs-next.md`'s concurrency section. | operator (the page only displays it) |
18
+ | **Secrets are write-only; environment variables are readable** | `<their cloud>/settings/secrets` and `<their cloud>/settings/environment`, plus the per-repository sections under `<their cloud>/settings/repositories` | A name is a secret **or** an environment variable, never both; a repository-scoped value wins over a tenant-scoped one; an environment variable may reference a vault secret by name, expanded only when the container is built. | owner or admin |
19
+ | **"My secrets are not in the container"** | `<their cloud>/settings/repositories` (the team the repository is registered under) | When a repository was registered without naming its team, nothing reaches a runner, and the failure is silent — the symptom is a phase whose build cannot find its environment. The fix is an admin or operator action, not a retry; raise it as an ask. Name the symptom and the page, never the registry table behind it. | owner or admin |
20
+ | **Routing has no write route and no UI** | none — there is no settings page for this | Stage defaults are global and the per-tenant precedence table is empty, so an empty precedence always resolves to the default provider. A Codex-first pipeline is not something to promise today. See `references/coding-accounts.md` and `references/what-runs-next.md`'s routing decision. | nobody — not configurable yet |
@@ -17,6 +17,16 @@ The queue re-derives within seconds of any board change and at the top of every
17
17
  - Dispatch buckets by repository and splits slots across teams so no team starves another. A slot that is running, might be running, or is restarting is never free capacity.
18
18
  - Comment-wake work (an agent answering a human comment) shares the same cap as ladder work.
19
19
 
20
+ ## The WIP limit
21
+
22
+ A Catalyst project (one Linear team, across every repository registered to it) starts no new ticket while its work in progress is at its limit. The limit is per project, never per repository and never workspace-wide: the project's own stored setting when there is one, else a tenant-wide value the operator set, else 12. `0` is a real limit and holds every new start. A workspace owner or admin sets it per project through the project-settings route; there is no page for it yet.
23
+
24
+ It holds only new starts. A ticket in the dispatch column that has never run gets no first phase while the team is at its limit, and the explainer prints `wip_limit` with a detail such as "14 tickets in progress in <team key>, at or above its WIP limit of 12; this ticket starts once one finishes". A ticket that has already started keeps getting every later phase, whatever the count.
25
+
26
+ What counts as in progress: every live ticket of the team that is past the dispatch column, or was granted a first phase even if its card still sits there, and is not in a terminal state (done, canceled, duplicate). Blocked, parked, waiting on the merge queue or waiting on a human all count: the count is tickets, not running containers. What does not count: triage and backlog states, a dispatch-column ticket that never started, a ticket fenced for a worker outside the cloud (the local-lane label), and a ticket whose PR merged and that only the runner-pin gate holds.
27
+
28
+ How to read a fleet idle at the limit: nothing runs because the tickets in progress are all waiting, on the merge queue, on a hold, on a paused repository or on a person. Unstick those (`unstick`, `catalyst-github`, `what-needs-me`); the limit is doing its job. Raising the limit is the project owner's call, not a repair.
29
+
20
30
  ## The routing decision, in five checks
21
31
 
22
32
  When a phase is about to start, a route is chosen per candidate, in order, and the first survivor wins:
@@ -62,7 +72,15 @@ No survivor is `no_eligible_account_slot` when any candidate was skipped on capa
62
72
  | `retry_backoff` | retrying in place, waiting out its 2/5/15-minute rung |
63
73
  | `routing_unavailable` | claimed then refused at kickoff: no route, no eligible slot, or the provider is unavailable; the detail names which |
64
74
  | `repo_paused` | an operator paused the repository |
65
- | `remediate_parked` | the remediate phase is parked, so the failing phase has nowhere to be repaired |
75
+ | `wip_limit` | the project is at its WIP limit (see above): its tickets in progress are waiting, and this new start waits until one of them finishes |
76
+ | `remediate_parked` | the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst-skills release <ticket>` releases the park once its cause is fixed |
77
+ | `phase_parked` | the offered phase is parked after repeated failures or a spent repair-round cap; the person releases it once its cause is fixed |
78
+ | `later_phase_lease_held` | an earlier phase is offered while a live container still holds a later phase of this ticket |
79
+ | `human_owned_pr` | a person's own pull request holds this ticket; that person closes or merges the PR themselves, and no release command clears it |
80
+ | `review_not_converging` | review and repair kept finding new problems without converging |
81
+ | `round_threshold` | the ticket spent its lifetime repair budget |
82
+ | `claim_storm` | claimed too many times in the last hour; it waits the hour out, and there is nothing to release |
83
+ | `repo_at_capacity` | the repository's runner seats are all in use; it starts when one frees |
66
84
 
67
85
  ## The unknowns (the evaluator fails closed)
68
86
 
@@ -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.4 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — 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.4 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
9
9
 
10
10
  # Unstick
11
11