@catalyst-cloud/catalyst-skills 0.2.0 → 0.3.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 (48) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +39 -29
  3. package/bin/catalyst-skills.js +42 -2
  4. package/dist/args.js +14 -8
  5. package/dist/ask.js +26 -5
  6. package/dist/cli.js +20 -8
  7. package/dist/config.js +1 -1
  8. package/dist/contract.js +2 -2
  9. package/dist/execution.js +165 -13
  10. package/dist/http.js +55 -3
  11. package/dist/prompt.js +1 -1
  12. package/dist/query.js +42 -4
  13. package/dist/ready.js +2 -2
  14. package/package.json +4 -3
  15. package/skills/catalyst-github/SKILL.md +1 -1
  16. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  17. package/skills/catalyst-github/scripts/lib/cli.mjs +2 -2
  18. package/skills/catalyst-github/scripts/read-pr.mjs +1 -1
  19. package/skills/catalyst-linear/SKILL.md +1 -1
  20. package/skills/catalyst-linear/references/reading-a-ticket.md +2 -2
  21. package/skills/catalyst-linear/references/writing-to-linear.md +1 -1
  22. package/skills/catalyst-linear/scripts/lib/cli.mjs +1 -1
  23. package/skills/catalyst-setup/SKILL.md +3 -3
  24. package/skills/catalyst-setup/references/what-each-check-means.md +6 -5
  25. package/skills/catalyst-setup/scripts/lib/cli.mjs +2 -2
  26. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  27. package/skills/connect-me/SKILL.md +9 -9
  28. package/skills/connect-me/agents/openai.yaml +1 -1
  29. package/skills/connect-me/scripts/lib/cli.mjs +2 -2
  30. package/skills/how-catalyst-works/SKILL.md +2 -2
  31. package/skills/how-catalyst-works/references/coding-accounts.md +2 -2
  32. package/skills/how-catalyst-works/references/when-a-phase-fails.md +2 -2
  33. package/skills/how-catalyst-works/scripts/lib/cli.mjs +1 -1
  34. package/skills/how-catalyst-works/scripts/whats-running.mjs +1 -2
  35. package/skills/run-this-project/SKILL.md +2 -2
  36. package/skills/run-this-project/references/reacting-to-events.md +1 -1
  37. package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
  38. package/skills/run-this-project/scripts/lib/cli.mjs +2 -2
  39. package/skills/what-needs-me/SKILL.md +2 -2
  40. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  41. package/skills/what-needs-me/scripts/inbox.mjs +15 -6
  42. package/skills/what-needs-me/scripts/lib/cli.mjs +2 -2
  43. package/skills/whats-happening/SKILL.md +3 -3
  44. package/skills/whats-happening/assets/status-reply.json +1 -1
  45. package/skills/whats-happening/references/reading-the-board.md +1 -1
  46. package/skills/whats-happening/references/status-reply.md +1 -1
  47. package/skills/whats-happening/references/why-is-it-stuck.md +1 -1
  48. package/skills/whats-happening/scripts/lib/cli.mjs +2 -2
package/dist/execution.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { flagBool, flagString, positionals } from "./args.js";
5
5
  import { normalizeBaseUrl, requireConfig } from "./config.js";
6
6
  import { loadContract } from "./contract.js";
7
- import { UsageError } from "./errors.js";
7
+ import { CliError, UsageError } from "./errors.js";
8
8
  import { apiClient } from "./http.js";
9
9
  /** Every exclusion reason the eligibility evaluator names, in plain English. */
10
10
  export const EXCLUSION_REASONS = {
@@ -101,10 +101,9 @@ export async function cmdExplain(args, ctx) {
101
101
  if (!ticket)
102
102
  throw new UsageError("explain needs a ticket: explain <ticket>");
103
103
  const cfg = requireConfig(ctx);
104
- if (flagBool(args, "history")) {
105
- ctx.stdout(`${ticket}: per-ticket execution history is not visible to an account key yet — read it at ${normalizeBaseUrl(cfg.baseUrl)}/settings`);
106
- return 0;
107
- }
104
+ // `explain --history` is the same read as `history <ticket>`; both go to CTC-1954's route.
105
+ if (flagBool(args, "history"))
106
+ return await cmdHistory(args, ctx, ticket);
108
107
  const { doc } = await loadContract(ctx, cfg);
109
108
  const dash = ticket.indexOf("-");
110
109
  if (dash <= 0)
@@ -124,33 +123,186 @@ export async function cmdExplain(args, ctx) {
124
123
  }
125
124
  return 0;
126
125
  }
126
+ /**
127
+ * `running` — the fleet-wide "what is Catalyst doing right now?".
128
+ *
129
+ * ⛔ `/api/v1/lease/attributions` IS NOT A FLEET-WIDE ROUTE. It answers "who wrote this transition,
130
+ * holding which lease?" for ONE (ticket, phase) pair and 400s `invalid_field` without both — 0.2.0
131
+ * called it bare on every `running`, so the headline verb (and `whats-running.mjs`, and
132
+ * `snapshot.mjs`) failed for every tenant. The tenant-wide question is answered by
133
+ * `/fleet-activity/current` (what is executing) and `/agent-roster/current` (who is coordinating),
134
+ * neither of which takes coordinates. Lease attributions stay available, behind the coordinates the
135
+ * route requires.
136
+ */
127
137
  export async function cmdRunning(args, ctx) {
128
138
  const cfg = requireConfig(ctx);
139
+ const ticket = flagString(args, "ticket");
140
+ const phase = flagString(args, "phase");
141
+ if ((ticket === undefined) !== (phase === undefined)) {
142
+ throw new UsageError("running: --ticket and --phase go together — lease attributions are recorded per (ticket, phase), and the route refuses either one alone");
143
+ }
129
144
  const api = apiClient(cfg, ctx);
130
- const [activity, roster, leases] = await Promise.all([
145
+ const [activity, roster] = await Promise.all([
131
146
  api.getJson("/api/v1/fleet-activity/current"),
132
147
  api.getJson("/api/v1/agent-roster/current"),
133
- api.getJson("/api/v1/lease/attributions"),
134
148
  ]);
135
- const out = { fleetActivity: activity.body, agentRoster: roster.body, leaseAttributions: leases.body };
149
+ const out = { fleetActivity: activity.body, agentRoster: roster.body };
150
+ if (ticket !== undefined && phase !== undefined) {
151
+ const leases = await api.getJson("/api/v1/lease/attributions", { query: { ticket, phase } });
152
+ out.leaseAttributions = leases.body;
153
+ }
136
154
  if (args.json) {
137
155
  ctx.stdout(JSON.stringify(out));
138
156
  return 0;
139
157
  }
140
158
  ctx.stdout(`fleet activity: ${JSON.stringify(out.fleetActivity)}`);
141
159
  ctx.stdout(`agent roster: ${JSON.stringify(out.agentRoster)}`);
142
- ctx.stdout(`lease attributions: ${JSON.stringify(out.leaseAttributions)}`);
160
+ if (out.leaseAttributions !== undefined) {
161
+ ctx.stdout(`lease attributions (${ticket} ${phase}): ${JSON.stringify(out.leaseAttributions)}`);
162
+ }
143
163
  return 0;
144
164
  }
165
+ /**
166
+ * `queue` — the dispatch order. The route is per-team and REQUIRES `?team=` ("bad team", 400), but
167
+ * a customer asking "what is next?" rarely means one team, so an omitted `--team` reads every team
168
+ * the tenant contract names rather than sending a call the route will refuse.
169
+ */
145
170
  export async function cmdQueue(args, ctx) {
146
171
  const cfg = requireConfig(ctx);
147
172
  const api = apiClient(cfg, ctx);
148
- const res = await api.getJson("/api/v1/dispatch-queue/current", { query: { team: flagString(args, "team") } });
149
- ctx.stdout(args.json ? JSON.stringify(res.body) : JSON.stringify(res.body, null, 2));
173
+ const named = flagString(args, "team");
174
+ let teams;
175
+ if (named !== undefined) {
176
+ teams = [named];
177
+ }
178
+ else {
179
+ const { doc } = await loadContract(ctx, cfg);
180
+ teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
181
+ if (teams.length === 0) {
182
+ throw new UsageError("queue needs --team: the tenant contract names no team to read a queue for");
183
+ }
184
+ }
185
+ const bodies = await Promise.all(teams.map(async (team) => [team, (await api.getJson("/api/v1/dispatch-queue/current", { query: { team } })).body]));
186
+ // One team asked for → its envelope, unwrapped, exactly as before. Several → keyed by team, so a
187
+ // caller can tell whose queue a row belongs to.
188
+ const out = bodies.length === 1 && named !== undefined ? bodies[0][1] : Object.fromEntries(bodies);
189
+ ctx.stdout(args.json ? JSON.stringify(out) : JSON.stringify(out, null, 2));
150
190
  return 0;
151
191
  }
152
- export function cmdAccounts(ctx) {
192
+ /** The five status words `/api/v1/coding-accounts` reports, in the words a customer reads. */
193
+ export const ACCOUNT_STATUS = {
194
+ "expired-or-revoked": "expired or revoked — re-enrol it before it can take work",
195
+ walled: "walled — the provider's usage limit is spent for now",
196
+ active: "active — observed working",
197
+ attested: "attested — healthy at last check, no work observed since",
198
+ unobserved: "unobserved — enrolled, but nothing has been seen from it yet",
199
+ };
200
+ /** One slot as a line a human reads: who it is, what state it is in, and what it is spending on. */
201
+ export function renderAccount(a) {
202
+ const name = a.label ? `${a.accountSlot ?? "?"} (${a.label})` : (a.accountSlot ?? "?");
203
+ const harness = a.harness ? `/${a.harness}` : "";
204
+ const status = a.status ? (ACCOUNT_STATUS[a.status] ?? a.status) : "status unknown";
205
+ const bits = [`${name} ${a.provider ?? "?"}${harness} ${status}`];
206
+ if (typeof a.bindingUsedPercent === "number") {
207
+ const resets = typeof a.bindingResetsAtMs === "number" ? `, resets ${new Date(a.bindingResetsAtMs).toISOString()}` : "";
208
+ bits.push(`usage ${a.bindingUsedPercent}% of the ${a.bindingWindow ?? "binding"} window${resets}`);
209
+ }
210
+ if (a.quarantined)
211
+ bits.push(`quarantined${a.quarantineReason ? `: ${a.quarantineReason}` : ""}`);
212
+ const holds = a.liveHolds ?? [];
213
+ if (holds.length > 0)
214
+ bits.push(`holding ${holds.map((h) => `${h.ticket}/${h.phase}`).join(", ")}`);
215
+ else if (a.liveHoldsCount)
216
+ bits.push(`${a.liveHoldsCount} live hold(s)`);
217
+ return bits.join(" · ");
218
+ }
219
+ /**
220
+ * ⛔ A ROUTE THIS TENANT'S CLOUD DOES NOT SERVE IS SAID OUT LOUD, never rendered as an empty success.
221
+ * `GET /api/v1/coding-accounts` (CTC-1953) and `GET /api/v1/issues/:id/execution` (CTC-1954) ship
222
+ * ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
223
+ * which is a different fact from "you have no coding accounts" and must never print as the latter.
224
+ */
225
+ function needsNewerCloud(what, cfg) {
226
+ return new CliError(`${what} needs a newer Catalyst Cloud than ${normalizeBaseUrl(cfg.baseUrl)} is running — the route answered 404. Nothing is wrong with your tenant; ask your operator when the mirror last deployed.`, "route-not-deployed", 3, 404);
227
+ }
228
+ export async function cmdAccounts(args, ctx) {
153
229
  const cfg = requireConfig(ctx);
154
- ctx.stdout(`coding-account status is not visible to an account key yet — read it at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
230
+ const api = apiClient(cfg, ctx);
231
+ const res = await api.getJson("/api/v1/coding-accounts", {
232
+ accept: [404],
233
+ });
234
+ if (res.status === 404)
235
+ throw needsNewerCloud("coding-account status", cfg);
236
+ const accounts = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
237
+ if (args.json) {
238
+ ctx.stdout(JSON.stringify(res.body));
239
+ return 0;
240
+ }
241
+ if (accounts.length === 0) {
242
+ ctx.stdout(`No coding accounts are enrolled on this tenant — enrol one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
243
+ return 0;
244
+ }
245
+ for (const a of accounts)
246
+ ctx.stdout(renderAccount(a));
155
247
  return 0;
156
248
  }
249
+ /**
250
+ * `history <ticket>` (and `explain --history`) — the ticket's execution history: per-phase attempts
251
+ * and outcomes, remediate rounds against the cap, the park sentinel and what releases it, the live
252
+ * lease, the last advance. Reads `GET /api/v1/issues/:identifier/execution` (CTC-1954).
253
+ */
254
+ export async function cmdHistory(args, ctx, ticketArg) {
255
+ const ticket = ticketArg ?? positionals(args)[0];
256
+ if (!ticket)
257
+ throw new UsageError("history needs a ticket: history <ticket>");
258
+ const cfg = requireConfig(ctx);
259
+ const api = apiClient(cfg, ctx);
260
+ const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}/execution`, {
261
+ accept: [404],
262
+ });
263
+ if (res.status === 404)
264
+ throw needsNewerCloud(`execution history for ${ticket}`, cfg);
265
+ if (args.json) {
266
+ ctx.stdout(JSON.stringify(res.body));
267
+ return 0;
268
+ }
269
+ for (const line of renderHistory(ticket, res.body))
270
+ ctx.stdout(line);
271
+ return 0;
272
+ }
273
+ /** ⛔ `null` is UNREADABLE, never "nothing happened" — the report's own contract, kept in the prose. */
274
+ export function renderHistory(ticket, doc) {
275
+ const lines = [];
276
+ if (doc.hasLadderHistory === false) {
277
+ lines.push(`${ticket}: no ladder history recorded${doc.note ? ` — ${doc.note}` : ""}.`);
278
+ }
279
+ const phases = doc.phases;
280
+ if (phases === null || phases === undefined) {
281
+ lines.push(`${ticket}: the per-phase table could not be read (unreadable, not empty).`);
282
+ }
283
+ else {
284
+ lines.push(`${ticket} phases (${doc.attemptHistory ?? "latest-per-phase"}):`);
285
+ for (const p of phases) {
286
+ const fail = p.lastFailureClass ? `, last failure ${p.lastFailureClass}${p.consecutiveFailures ? ` ×${p.consecutiveFailures}` : ""}` : "";
287
+ lines.push(` ${p.phase}: ${p.status ?? "?"} (attempt ${p.attempt ?? "?"})${fail}`);
288
+ }
289
+ }
290
+ if (doc.failure) {
291
+ const f = doc.failure;
292
+ lines.push(`Last failure: ${f.phase} — ${f.failureMode}${f.failureDetail ? ` (${f.failureDetail})` : ""}${f.summary ? `: ${f.summary}` : ""}`);
293
+ }
294
+ if (doc.remediate && typeof doc.remediate.roundsDispatched === "number") {
295
+ lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
296
+ }
297
+ if (doc.park) {
298
+ lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "needs an operator"} — ${doc.park.releasedBy}`);
299
+ }
300
+ for (const l of doc.lease ?? [])
301
+ lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
302
+ if (doc.lastAdvance) {
303
+ lines.push(`Last advance: ${doc.lastAdvance.phase} → ${doc.lastAdvance.toSlot ?? "?"} (${doc.lastAdvance.landed ? "landed" : "not landed"})`);
304
+ }
305
+ for (const u of doc.unreadable ?? [])
306
+ lines.push(`Unreadable: ${u.table} (${u.error}) — absent from this report, not absent from the ticket.`);
307
+ return lines;
308
+ }
package/dist/http.js CHANGED
@@ -31,6 +31,39 @@ export class ApiClient {
31
31
  await this.refuseIfNotOk(res, `GET ${path}`);
32
32
  return { status: res.status, body: await this.parseBody(res, `GET ${path}`), headers: res.headers };
33
33
  }
34
+ /**
35
+ * A GET whose success body is NDJSON — one JSON object per line, the shape `streamNdjson` sends.
36
+ *
37
+ * ⛔ `/changes` and `/snapshot` ANSWER NDJSON ON 200 AND JSON ON A REFUSAL, so a caller that only
38
+ * has `getJson` reads every real success as "returned a non-JSON body" while its refusals parse
39
+ * perfectly. That asymmetry is why `query changes` looked like it worked: nothing reached a 200.
40
+ * `accept` behaves as it does on `getJson` — those statuses come back with `rows: []` for the
41
+ * caller to branch on, and their (JSON) body is deliberately not parsed here.
42
+ */
43
+ async getNdjson(path, opts = {}) {
44
+ const url = this.url(path, opts.query);
45
+ const res = await this.send(url, {
46
+ method: "GET",
47
+ headers: { authorization: `Bearer ${this.cfg.key}`, accept: "application/x-ndjson, application/json" },
48
+ });
49
+ if (opts.accept?.includes(res.status))
50
+ return { status: res.status, body: [], headers: res.headers };
51
+ await this.refuseIfNotOk(res, `GET ${path}`);
52
+ const text = await res.text();
53
+ const rows = [];
54
+ for (const line of text.split("\n")) {
55
+ const trimmed = line.trim();
56
+ if (trimmed === "")
57
+ continue;
58
+ try {
59
+ rows.push(JSON.parse(trimmed));
60
+ }
61
+ catch {
62
+ throw new MeError(`GET ${path} returned a line that is not JSON: ${trimmed.slice(0, 120)}`, "shape");
63
+ }
64
+ }
65
+ return { status: res.status, body: rows, headers: res.headers };
66
+ }
34
67
  async postJson(path, body) {
35
68
  const url = this.url(path);
36
69
  const res = await this.send(url, {
@@ -75,10 +108,10 @@ export class ApiClient {
75
108
  reason = raw.slice(0, 200);
76
109
  }
77
110
  if (res.status === 401) {
78
- throw new CliError(`${what} failed (401): credential not accepted — ask your tenant admin for a valid account key`, "unauthorized", 2, 401);
111
+ throw new CliError(`${what} failed (401): credential not accepted — mint a personal key at Settings → API keys and log in again`, "unauthorized", 2, 401);
79
112
  }
80
113
  if (res.status === 403) {
81
- throw new CliError(`${what} failed (403): this key may not reach that route${reason ? ` (${reason})` : ""} — a machine-gated route needs an account key (ctc_acct_…), not a workstation key`, "forbidden", 2, 403);
114
+ throw new CliError(`${what} failed (403): this key may not reach that route${reason ? ` (${reason})` : ""} — a route that acts as a host (claiming work, publishing artifacts) needs the tenant's account key; every read and write a person makes takes your personal key`, "forbidden", 2, 403);
82
115
  }
83
116
  if (res.status === 429) {
84
117
  const retryAfter = res.headers.get("retry-after");
@@ -114,7 +147,7 @@ export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEO
114
147
  reason = "";
115
148
  }
116
149
  const detail = res.status === 401
117
- ? "credential not accepted — ask your tenant admin for a valid account key"
150
+ ? "credential not accepted — mint a personal key at Settings → API keys and log in again"
118
151
  : reason || `HTTP ${res.status}`;
119
152
  throw new MeError(`GET /me failed (${res.status}): ${detail}`, "http", res.status);
120
153
  }
@@ -132,11 +165,30 @@ export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEO
132
165
  (body.principal !== "service" && body.principal !== "session")) {
133
166
  throw new MeError("GET /me returned an unexpected shape", "shape");
134
167
  }
168
+ const user = parseMeUser(body.user);
135
169
  return {
136
170
  account: body.account,
137
171
  slug: body.slug,
138
172
  name: body.name,
139
173
  permissions: body.permissions,
140
174
  principal: body.principal,
175
+ ...(user ? { user } : {}),
141
176
  };
142
177
  }
178
+ /** The `user` block a personal key gets from /me. Absent (undefined) is "a host key, no person";
179
+ * present-but-malformed is a shape error like any other field, never silently dropped. */
180
+ function parseMeUser(raw) {
181
+ if (raw === undefined)
182
+ return undefined;
183
+ if (typeof raw !== "object" || raw === null)
184
+ throw new MeError("GET /me returned an unexpected user shape", "shape");
185
+ const u = raw;
186
+ if (typeof u.id !== "string" ||
187
+ typeof u.label !== "string" ||
188
+ (u.email !== null && typeof u.email !== "string") ||
189
+ (u.role !== "owner" && u.role !== "admin" && u.role !== "member") ||
190
+ (u.linearUserId !== null && typeof u.linearUserId !== "string")) {
191
+ throw new MeError("GET /me returned an unexpected user shape", "shape");
192
+ }
193
+ return { id: u.id, label: u.label, email: u.email, role: u.role, linearUserId: u.linearUserId };
194
+ }
package/dist/prompt.js CHANGED
@@ -1,4 +1,4 @@
1
- // prompt.ts — the one interactive read in the CLI: asking for the account key on a terminal without
1
+ // prompt.ts — the one interactive read in the CLI: asking for the personal key on a terminal without
2
2
  // echoing it. The streams are parameters so the tests can drive it without a real TTY.
3
3
  import { createInterface } from "node:readline";
4
4
  /** True when there is a terminal to prompt on. Both ends matter: the question needs somewhere to go. */
package/dist/query.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // them and the read-model package is not a declared dependency of this bundle).
5
5
  import { flagInt, flagString, positionals } from "./args.js";
6
6
  import { requireConfig, replicaDbPath } from "./config.js";
7
- import { UsageError } from "./errors.js";
7
+ import { CliError, UsageError } from "./errors.js";
8
8
  import { apiClient } from "./http.js";
9
9
  import { engineFor, replicaStatus } from "./replica.js";
10
10
  import { loadSdk } from "./sdk.js";
@@ -125,14 +125,52 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
125
125
  case "changes": {
126
126
  const since = flagString(args, "since");
127
127
  if (since === undefined)
128
- throw new UsageError("changes needs --since <cursor>");
129
- const res = await api.getJson("/api/v1/changes", { query: { since, limit: f.limit } });
130
- return res.body;
128
+ throw new UsageError("changes needs --since <cursor>, or --since head to start from now");
129
+ return await readChanges(api, since, f.limit);
131
130
  }
132
131
  default:
133
132
  throw new UsageError(`unknown query subcommand: ${sub}`);
134
133
  }
135
134
  }
135
+ /** CTC-137 stamps the live head seq on EVERY `/changes` response — the 200 stream and both 409s. */
136
+ const HEAD_SEQ_HEADER = "x-catalyst-head-seq";
137
+ /**
138
+ * `query changes --since <cursor|head>`.
139
+ *
140
+ * ⛔ THE CHANGEFEED EVICTS, so a refusal must name a cursor that works. `buildChanges` answers a
141
+ * `since` below the oldest retained seq with 409 `cursor_underflow`, and one past the head with 409
142
+ * `cursor_ahead_of_head` — both `{resync: true}`. `--since 0`, the form the docs show, is therefore
143
+ * a 409 on any tenant whose log has rotated, and 0.2.0 surfaced it as a bare "GET /api/v1/changes
144
+ * failed (409)" that left the customer with no way to learn a usable cursor. The head is stamped on
145
+ * the refusal itself, so there is always something actionable to say.
146
+ */
147
+ async function readChanges(api, since, limit) {
148
+ const resolved = since === "head" ? String(await headCursor(api)) : since;
149
+ // ⛔ NDJSON ON 200, JSON ON A REFUSAL — see `getNdjson`. Reading this with `getJson` turned every
150
+ // real success into "returned a non-JSON body" while every refusal parsed, which is why the verb
151
+ // looked fine: `--since 0` 409s on a rotated feed, so no 200 was ever reached to fail on.
152
+ const res = await api.getNdjson("/api/v1/changes", {
153
+ query: { since: resolved, limit },
154
+ accept: [409],
155
+ });
156
+ const head = res.headers.get(HEAD_SEQ_HEADER);
157
+ if (res.status !== 409) {
158
+ return { since: Number(resolved), head: head === null ? null : Number(head), changes: res.body };
159
+ }
160
+ const where = head === null ? "" : ` The feed's live cursor is ${head}.`;
161
+ throw new CliError(`cursor ${resolved} is not usable: it is either past the feed's head or no longer in the change log, which keeps only recent changes.${where} Re-run with \`--since head\` to start from now, or with a cursor the feed still holds.`, "changefeed-resync", 1, 409);
162
+ }
163
+ /** The live head, read off any `/changes` response's own header — a 409 carries it too, which is
164
+ * what makes this work on a tenant whose log has rotated past every cursor the caller could guess. */
165
+ async function headCursor(api) {
166
+ const probe = await api.getNdjson("/api/v1/changes", { query: { since: "0", limit: 1 }, accept: [409] });
167
+ const raw = probe.headers.get(HEAD_SEQ_HEADER);
168
+ const head = raw === null ? Number.NaN : Number(raw);
169
+ if (!Number.isFinite(head)) {
170
+ throw new CliError(`--since head needs the feed's live cursor, and this cloud did not send one (no ${HEAD_SEQ_HEADER} header). Pass an explicit --since <cursor>.`, "changefeed-head-unknown", 1);
171
+ }
172
+ return head;
173
+ }
136
174
  function needArg(rest, usage) {
137
175
  const v = rest[0];
138
176
  if (!v)
package/dist/ready.js CHANGED
@@ -23,7 +23,7 @@ export async function readyReport(ctx, deps) {
23
23
  cfg = loadConfig(ctx.home);
24
24
  checks.push(cfg
25
25
  ? { id: "config", ok: true, line: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}` }
26
- : { id: "config", ok: false, line: "config: not connected", fix: "CATALYST_CLOUD_TOKEN=<account key> npx @catalyst-cloud/catalyst-skills login", who: "you (the key comes from your tenant admin)" });
26
+ : { id: "config", ok: false, line: "config: not connected", fix: "CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login", who: "you (mint the key at Settings → API keys)" });
27
27
  }
28
28
  catch (err) {
29
29
  checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
@@ -31,7 +31,7 @@ export async function readyReport(ctx, deps) {
31
31
  const cache = readContractCache(ctx.home);
32
32
  const range = readManifest().tenantContractRange;
33
33
  if (!cache) {
34
- checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh (needs an account key, not a workstation key)", who: "you" });
34
+ checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh", who: "you" });
35
35
  }
36
36
  else if (contractVersionInRange(cache.contractVersion, range) !== true) {
37
37
  checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: "npm update -g @catalyst-cloud/catalyst-skills", who: "you" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/catalyst-skills",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Customer skill bundle for Catalyst Cloud: eight skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, catalyst-setup, catalyst-linear, catalyst-github, how-catalyst-works, connect-me) plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit",
37
37
  "build": "tsc -p tsconfig.build.json",
38
- "prepack": "tsc -p tsconfig.build.json",
38
+ "prepare": "tsc -p tsconfig.build.json",
39
39
  "test": "vitest run",
40
40
  "test:coverage": "vitest run --coverage",
41
41
  "version:sync": "node scripts/sync-plugin-version.mjs"
@@ -47,6 +47,7 @@
47
47
  "@types/node": "^26.3.0",
48
48
  "@vitest/coverage-v8": "^4.1.11",
49
49
  "typescript": "^7.0.2",
50
- "vitest": "^4.1.11"
50
+ "vitest": "^4.1.11",
51
+ "yaml": "^2.9.0"
51
52
  }
52
53
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: catalyst-github
3
- description:
3
+ 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
  ---
@@ -42,7 +42,7 @@ Four labels matter, all named on `merge.prLabels`, all matched exactly (a queue'
42
42
  | `handStepsHold` | the cloud, automatically | the PR touches a path the queue configuration excludes (schema or migration paths, typically), so a person has to merge it by hand. |
43
43
  | `preview` | a human | "deploy me a preview and hold the merge until I have looked". Removing the label is the approval. |
44
44
 
45
- The mirror does not carry PR labels or reactions, so an account key cannot read which holds a PR carries or whether the reviewer reacted; the scripts say so by name. GitHub's own page, or the tenant's settings, is where those live.
45
+ The mirror does not carry PR labels or reactions, so your key cannot read which holds a PR carries or whether the reviewer reacted; the scripts say so by name. GitHub's own page, or the tenant's settings, is where those live.
46
46
 
47
47
  ## The queue is optional and opt-out
48
48
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // lib/cli.mjs — the one way a skill script reaches the Catalyst Cloud SDK and API: by spawning the
3
3
  // catalyst-skills CLI this bundle installed. It reads customer.json to find that CLI and nothing
4
- // else; it never holds the account key itself. This file is a library — run a sibling script with
4
+ // else; it never holds the key itself. This file is a library — run a sibling script with
5
5
  // --help for usage. Identical in every skill of this bundle on purpose (skills install one directory
6
6
  // at a time, so nothing shared outside the skill would ever be installed).
7
7
  import { spawnSync } from "node:child_process";
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
12
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
13
- export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<account key> npx ${PACKAGE} login`;
13
+ export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
14
14
 
15
15
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
16
16
  export function configPath() {
@@ -93,5 +93,5 @@ console.log(`reviews (${reviews.length}):`);
93
93
  if (reviews.length === 0) console.log(" none mirrored; a reviewer's clean pass is a reaction, which this read does not carry");
94
94
  for (const r of reviews) console.log(` ${fmt(r.state)} ${fmt(r.reviewer_name, fmt(r.reviewer_id))} ${when(r.submitted_at)}`);
95
95
 
96
- console.log("not visible to an account key here: PR labels (holds, queue attestation) and the reviewer's reaction — see references/what-a-pr-accumulates.md");
96
+ console.log("not mirrored here: PR labels (holds, queue attestation) and the reviewer's reaction — see references/what-a-pr-accumulates.md");
97
97
  process.exit(0);
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: catalyst-linear
3
- description:
3
+ description: >-
4
4
  Catalyst's view of Linear on the customer's own tenant. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the local replica when it is fresh and the origin-fresh API otherwise, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the tenant's agent proxy as the app actor, with every route, stage id, label id and marker read from the tenant contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  disable-model-invocation: true
@@ -34,9 +34,9 @@ The comment shapes in `references/what-a-ticket-accumulates.md` tell you which c
34
34
 
35
35
  A phase's transcript (the full session log) exists in the cloud per ticket, but this bundle has no verb for it yet. Read the artifact document first; it is the phase's own account of what it did. Reach for the transcript only when the document leaves the question open, and say that the bundle cannot fetch it so the human opens it from the ticket's attachments.
36
36
 
37
- ## What an account key cannot read
37
+ ## What the ledger adds to the comments
38
38
 
39
- Per-ticket execution history beyond the comments (the attempt ledger, the remediation round count against the cap, park state and what releases it) is not readable with an account key yet; `catalyst-skills explain --history <ticket>` prints that by name and where to read it. Do not reconstruct a round count from comments and present it as the cap's count.
39
+ Per-ticket execution history beyond the comments (the attempt ledger, the remediation round count against the cap, park state and what releases it) is `catalyst-skills explain --history <ticket>`, read from the cloud's own relay ledger. Do not reconstruct a round count from comments and present it as the cap's count; read it.
40
40
 
41
41
  ## How to cite
42
42
 
@@ -12,7 +12,7 @@ This reference restates the write mechanism. The live values it depends on — t
12
12
  | a new ticket | `node scripts/create-ticket.mjs --team <key> --title <text>` |
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
- Each script wraps one `catalyst-skills write` verb, which posts to the route the contract names, as the tenant's app actor, with the account key this machine connected with. No script composes a URL, and none needs a Linear credential of its own.
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.
16
16
 
17
17
  ## App actor versus personal identity
18
18
 
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
10
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
11
- export const CONNECT_HINT = `CATALYST_CLOUD_TOKEN=<account key> npx ${PACKAGE} login`;
11
+ export const CONNECT_HINT = `CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
12
12
 
13
13
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME (used by tests) over HOME. */
14
14
  export function configPath() {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: catalyst-setup
3
- description:
3
+ 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 ten 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
  ---
@@ -23,10 +23,10 @@ You answer one question with one verdict and one list. The verdict is READY or N
23
23
 
24
24
  ## Rules
25
25
 
26
- - Report, never repair. No tenant-reachable repair verb exists for an account key yet; the settings page is where a tenant owner or admin fixes a mapping, a connection or a label, and you say which one.
26
+ - Report, never repair. No tenant-reachable repair verb exists for a key yet; the settings page is where a tenant owner or admin fixes a mapping, a connection or a label, and you say which one.
27
27
  - End every answer with the verdict and the who-can-click-what list, in that order.
28
28
  - A stale or absent replica is a note, never a failure: every skill reads the API meanwhile and says so. Do not tell the person to start it unless they want local SQL or cheaper repeated reads.
29
- - Not connected (exit 2) means the connect step, not a retry: `CATALYST_CLOUD_TOKEN=<account key> npx @catalyst-cloud/catalyst-skills login`, with the key from their tenant admin. Never guess a tenant; the key is the only selector.
29
+ - Not connected (exit 2) means the connect step, not a retry: `CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login`, with the person's own key from Settings → API keys. Never guess a tenant; the key is the only selector.
30
30
  - Waiting is not failing. "No write observed", "no delivery observed" and "no host connected" clear themselves the first time the thing happens; say that instead of raising them.
31
31
  - Unknown is not a pass. A check the engine could not run is reported as such, never rounded up.
32
32
  - Never run a check in a loop. If the person wants to know when a waiting check clears, that is the project-running skill's watch.
@@ -4,9 +4,9 @@ This page restates invariants: the ten per-team readiness checks the cloud runs,
4
4
 
5
5
  ## How a team's readiness is scored
6
6
 
7
- A team's status is one of `ready`, `degraded`, `blocked` or `unchecked`. The engine always reports all ten checks; a check it could not run is `unknown`, never `pass`. Any failing check the contract marks as blocking makes the team blocked; any unknown, and any failing check marked degrading, makes it degraded. Unchecked means no readiness pass has run for that team yet, which is a note, not a failure. Readiness is stamped with the account-wide mapping revision it was computed against, so a stale verdict is visible as such.
7
+ A team's status is one of `ready`, `degraded`, `blocked` or `unchecked`. The engine always reports all eleven checks; a check it could not run is `unknown`, never `pass`. Any failing check the contract marks as blocking makes the team blocked; any unknown, and any failing check marked degrading, makes it degraded. Unchecked means no readiness pass has run for that team yet, which is a note, not a failure. Readiness is stamped with the account-wide mapping revision it was computed against, so a stale verdict is visible as such.
8
8
 
9
- ## The ten checks
9
+ ## The eleven checks
10
10
 
11
11
  | check id | proves | when it fails, the fix | who clicks |
12
12
  | -- | -- | -- | -- |
@@ -20,6 +20,7 @@ A team's status is one of `ready`, `degraded`, `blocked` or `unchecked`. The eng
20
20
  | `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 |
21
21
  | `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 |
22
22
  | `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 |
23
+ | `environment_declared` | a committed environment declaration for the team's default repository is in effect (`.catalyst/environment.json` 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 |
23
24
 
24
25
  Three checks are informational by design (labels, event delivery, host currency): they degrade a team but never block it. Which are which is served on `readinessChecks[].severity`; do not memorise the split.
25
26
 
@@ -36,8 +37,8 @@ Three checks are informational by design (labels, event delivery, host currency)
36
37
  | id | proves | fix |
37
38
  | -- | -- | -- |
38
39
  | `node` | Node 22 or newer, which the SDK's built-in SQLite engine needs | install Node 22+ |
39
- | `config` | this machine is connected: `customer.json` exists and loads | `CATALYST_CLOUD_TOKEN=<account key> npx @catalyst-cloud/catalyst-skills login`; the key comes from the tenant admin |
40
- | `contract` | the tenant contract is cached and its major version is one this bundle accepts | `catalyst-skills contract --refresh`; a version outside the range means update the bundle. A refresh needs an account key, not a workstation key |
40
+ | `config` | this machine is connected: `customer.json` exists and loads | `CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login`; the person mints the key at Settings → API keys |
41
+ | `contract` | the tenant contract is cached and its major version is one this bundle accepts | `catalyst-skills contract --refresh`; a version outside the range means update the bundle. A 403 naming an older cloud means the cloud has not yet deployed personal-key access |
41
42
  | `cliPath` | the CLI path recorded at login still exists, so skill scripts can spawn it | re-run login |
42
43
  | `skills` | every skill of this bundle is present where the CLI installed them | `catalyst-skills install` |
43
44
  | `sdk` | the SDK loads, so the replica and the watch are available | run under Node 22.15 or newer; every read still works through the API meanwhile |
@@ -58,4 +59,4 @@ Three checks are informational by design (labels, event delivery, host currency)
58
59
 
59
60
  ## Who can click what
60
61
 
61
- The last block `check.mjs` prints groups every failure by the person it needs. Machine fixes name "you", the person at the keyboard. Tenant fixes name the owner or admin roles the contract lists (as Linear user ids, since that is how the cloud knows them), because the settings page that repairs a mapping, a connection or a label is theirs. A check the contract marks as not needing an answer names nobody: it is informational or self-clearing. This skill reports; it never repairs, because no tenant-reachable repair verb exists for an account key yet.
62
+ The last block `check.mjs` prints groups every failure by the person it needs. Machine fixes name "you", the person at the keyboard. Tenant fixes name the owner or admin roles the contract lists (as Linear user ids, since that is how the cloud knows them), because the settings page that repairs a mapping, a connection or a label is theirs. A check the contract marks as not needing an answer names nobody: it is informational or self-clearing. This skill reports; it never repairs, because no key-reachable repair verb exists yet.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // lib/cli.mjs — the one way a skill script reaches the Catalyst Cloud SDK and API: by spawning the
3
3
  // catalyst-skills CLI this bundle installed. It reads customer.json to find that CLI and nothing
4
- // else; it never holds the account key itself. This file is a library — run a sibling script with
4
+ // else; it never holds the key itself. This file is a library — run a sibling script with
5
5
  // --help for usage. Identical in every skill of this bundle on purpose (skills install one directory
6
6
  // at a time, so nothing shared outside the skill would ever be installed).
7
7
  import { spawnSync } from "node:child_process";
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
12
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
13
- export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<account key> npx ${PACKAGE} login`;
13
+ export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
14
14
 
15
15
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
16
16
  export function configPath() {
@@ -14,7 +14,7 @@ const HELP = `Usage: node scripts/replica-status.mjs [--probe] [--json]
14
14
  Exit codes, passed through from catalyst-skills replica status:
15
15
  0 fresh: a live writer, a young heartbeat, a cursor — skills read the replica
16
16
  1 stale: the file exists but the writer is gone or behind — skills read the API and say so
17
- 2 not connected to a tenant — run: CATALYST_CLOUD_TOKEN=<account key> npx @catalyst-cloud/catalyst-skills login
17
+ 2 not connected to a tenant — run: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login
18
18
  3 absent: no replica file — optional; start one with: catalyst-skills replica start --detach`;
19
19
 
20
20
  const args = process.argv.slice(2);