@catalyst-cloud/catalyst-skills 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ Fixes five defects found by running 0.2.0's verbs against a live tenant. Three skills (catalyst-github, catalyst-setup, how-catalyst-works) carried a colon-and-space inside an unquoted description, which YAML reads as a nested map rather than text, so `npx skills add` skipped them and still reported success — every Codex, Cursor and OpenCode install of 0.2.0 quietly got five of the eight skills, and reinstalling with this version gives you all eight. `running` no longer asks for lease attributions it has no coordinates for, so the headline "what's happening?" verb works instead of failing for every tenant; the lease read is still available as `running --ticket T --phase P`. The CLI no longer truncates its own output at 64KB when a skill reads it through a pipe, which made `query issues --json` on a tenant past roughly eighty tickets return invalid JSON with a success code. `accounts` now actually reads your coding-account slots and renders them, `history <ticket>` (also `explain --history`) shows a ticket's execution history, and both say plainly when your cloud is older than this bundle rather than printing an empty success. `queue` with no `--team` reads every team on your tenant instead of being refused, as its help always promised. `query changes` works at all now: it reads the feed's NDJSON, where before it called any successful response a non-JSON body — nothing noticed because the documented `--since 0` is refused on any tenant whose feed has rotated, so a success was never reached. That refusal now names a cursor you can actually use, and `--since head` starts from now.
6
+
3
7
  ## 0.2.0
4
8
 
5
9
  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) replace the six; reads and the live event stream go through the Catalyst Cloud SDK and writes through the tenant's agent proxy, all behind catalyst-skills verbs (me, contract, query, replica, explain, running, queue, watch, write, ask, ready, accounts), with every stage id, label id, threshold and route read from the tenant contract; Node 22 is now required; installing the skills has moved to your agent's own command (the Claude Code plugin marketplace in this repository, or npx skills add for Codex, Cursor, OpenCode and the rest) and the CLI no longer copies them, leaving install only as a repair path; catalyst-skills login replaces join, which stays as a deprecated alias for one minor version, and login now prompts for the key without echoing it when no CATALYST_CLOUD_TOKEN is set; every SKILL.md declares allowed-tools scoped to this package's binary, so no skill needs a blanket shell grant; your existing customer.json is still read unchanged and gains the CLI path the next time you run catalyst-skills login, which also caches the tenant contract; run catalyst-skills ready to confirm.
@@ -1,10 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  // Thin ESM launcher for @catalyst-cloud/catalyst-skills (CTC-1926). tsc does not carry a shebang
3
3
  // through emit, so the bin is this two-line wrapper and the program lives in dist/cli.js.
4
+ //
5
+ // ⛔ NEVER `process.exit(code)` STRAIGHT AFTER A WRITE. When stdout is a PIPE — which is how every
6
+ // skill script spawns this CLI — writes are asynchronous, so `process.exit` severs whatever is
7
+ // still in flight AND STILL REPORTS THE ORIGINAL EXIT CODE. The caller then reads a truncated body
8
+ // with a success code and has no way to tell it from a complete one: `query issues --json` on a
9
+ // tenant with ~80+ tickets came back as 65,528 bytes of invalid JSON, exit 0 (0.2.0). Interactive
10
+ // use hid it, because a tty gives stdout a synchronous write path.
11
+ //
12
+ // So: park the code on `process.exitCode`, wait for both streams to drain, and only then exit. The
13
+ // explicit exit stays — `fetch`'s keep-alive sockets can hold the event loop open for seconds after
14
+ // the work is done, and a CLI that lingers is its own defect — but by then nothing is buffered.
4
15
  import("../dist/cli.js")
5
16
  .then((m) => m.main(process.argv.slice(2)))
6
- .then((code) => process.exit(code))
17
+ .then((code) => finish(code))
7
18
  .catch((err) => {
8
19
  console.error(`catalyst-skills: failed to load: ${err instanceof Error ? err.message : String(err)}`);
9
- process.exit(1);
20
+ finish(1);
10
21
  });
22
+
23
+ /**
24
+ * Resolve once every byte already handed to `stream` has reached the other end. The empty write is
25
+ * ordered behind the real ones, so its callback is the drain signal; a destroyed or errored stream
26
+ * (a reader that hung up) resolves immediately rather than hanging the process forever.
27
+ */
28
+ function drained(stream) {
29
+ return new Promise((resolve) => {
30
+ if (!stream || stream.destroyed || stream.writableEnded) return resolve();
31
+ let settled = false;
32
+ const done = () => {
33
+ if (settled) return;
34
+ settled = true;
35
+ resolve();
36
+ };
37
+ stream.once("error", done);
38
+ try {
39
+ stream.write("", done);
40
+ } catch {
41
+ done();
42
+ }
43
+ });
44
+ }
45
+
46
+ async function finish(code) {
47
+ process.exitCode = code;
48
+ await Promise.all([drained(process.stdout), drained(process.stderr)]);
49
+ process.exit(code);
50
+ }
package/dist/args.js CHANGED
@@ -38,11 +38,15 @@ export const FLAG_TABLES = {
38
38
  "stale-ms": { value: true, help: "status: heartbeat age that counts as stale (default 15000)" },
39
39
  },
40
40
  explain: {
41
- history: { value: false, help: "per-ticket execution history (not visible to an account key yet)" },
41
+ history: { value: false, help: "per-ticket execution history instead of the eligibility reason" },
42
+ },
43
+ history: {},
44
+ running: {
45
+ ticket: { value: true, help: "with --phase: also the lease attributions for this (ticket, phase)" },
46
+ phase: { value: true, help: "with --ticket: the phase whose lease attributions to read" },
42
47
  },
43
- running: {},
44
48
  queue: {
45
- team: { value: true, help: "team key" },
49
+ team: { value: true, help: "team key (default: every team the tenant contract names)" },
46
50
  },
47
51
  watch: {
48
52
  team: { value: true, help: "scope: a team key" },
@@ -95,16 +99,17 @@ export const VERB_USAGE = {
95
99
  notice: "notice",
96
100
  me: "me [--json]",
97
101
  contract: "contract [--refresh] [--path <a.b.c>] [--json]",
98
- query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <n>> [--team K] [--project P] [--state S] [--limit N] [--source replica|api] [--json]",
102
+ query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--source replica|api] [--json]",
99
103
  replica: "replica <start [--detach]|stop|status [--probe] [--json]|sql \"<select>\"|schema [table]> [--db <path>]",
100
104
  explain: "explain <ticket> [--history] [--json]",
101
- running: "running [--json]",
105
+ history: "history <ticket> [--json]",
106
+ running: "running [--ticket T --phase P] [--json]",
102
107
  queue: "queue [--team K] [--json]",
103
108
  watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
104
109
  write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
105
110
  ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--json]>",
106
111
  ready: "ready [--json]",
107
- accounts: "accounts",
112
+ accounts: "accounts [--json]",
108
113
  };
109
114
  export function parseArgs(argv) {
110
115
  const out = {
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { CONFIG_MODE, DEFAULT_BASE_URL, PACKAGE_NAME, cliPath, configPathFor, co
6
6
  import { loadContract, pickPath } from "./contract.js";
7
7
  import { CliError, MeError, UsageError } from "./errors.js";
8
8
  import { fetchMe } from "./http.js";
9
- import { cmdAccounts, cmdExplain, cmdQueue, cmdRunning } from "./execution.js";
9
+ import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning } from "./execution.js";
10
10
  import { cmdQuery } from "./query.js";
11
11
  import { cmdReady } from "./ready.js";
12
12
  import { cmdReplica } from "./replica.js";
@@ -42,9 +42,9 @@ export function usageText() {
42
42
  " catalyst-skills install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
43
43
  " catalyst-skills status | notice | me | ready | accounts",
44
44
  " catalyst-skills contract [--refresh] [--path <a.b.c>]",
45
- " catalyst-skills query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <n>>",
45
+ " catalyst-skills query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
46
46
  " catalyst-skills replica <start [--detach]|stop|status [--probe]|sql \"<select>\"|schema [table]>",
47
- " catalyst-skills explain <ticket> | running | queue [--team K]",
47
+ " catalyst-skills explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
48
48
  " catalyst-skills watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
49
49
  " catalyst-skills write <comment|state|label|create|reaction|attachment|session> ...",
50
50
  " catalyst-skills ask <raise|accept|list> ...",
@@ -148,6 +148,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
148
148
  return await cmdReplica(args, ctx, deps.replica);
149
149
  case "explain":
150
150
  return await cmdExplain(args, ctx);
151
+ case "history":
152
+ return await cmdHistory(args, ctx);
151
153
  case "running":
152
154
  return await cmdRunning(args, ctx);
153
155
  case "queue":
@@ -161,7 +163,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
161
163
  case "ready":
162
164
  return await cmdReady(args, ctx, { skillNames: CUSTOMER_SKILLS, loadSdk: deps.loadSdk });
163
165
  case "accounts":
164
- return cmdAccounts(ctx);
166
+ return await cmdAccounts(args, ctx);
165
167
  default:
166
168
  ctx.stderr(`unknown command: ${args.command}`);
167
169
  ctx.stderr(usageText());
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, {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/catalyst-skills",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
  ---
@@ -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
@@ -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
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: connect-me
3
- description:
3
+ description: >-
4
4
  Connect this machine to the customer's Catalyst Cloud tenant with their account key, cache the tenant contract, and verify. Use when a person is getting started, when their config is missing or broken, when they rotated their key, when a skill script exits 2 saying the machine is not connected, or when they ask which tenant this machine belongs to. The key names the tenant, so nobody types an account id. Installing the skills is not this skill's job; the agent's own install command did that.
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: how-catalyst-works
3
- description:
3
+ description: >-
4
4
  How Catalyst Cloud runs a ticket on the customer's own tenant, as facts an agent loads on demand: the eight-phase ladder and what each phase produces, the eleven board slots and this team's live stage map, what happens when a phase fails (retry, backoff, Remediate, park), how the queue is ordered and routed and every reason a ticket is excluded, and the coding-account model. Use when a person asks "how does this work?", "why did it do that?", "why is this stuck?", "what runs next?" or "how does it prioritise?". Read-only; its scripts explain one ticket's eligibility in plain English, show what is running and queued, and print the tenant's stage map and thresholds straight from the contract.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: run-this-project
3
- description:
3
+ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the tenant stream for the scope through the catalyst-skills CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: what-needs-me
3
- description:
3
+ description: >-
4
4
  The human's decision inbox on a Catalyst Cloud tenant, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by what each answer releases, files an ask through the cloud's ask route with the tenant's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  disable-model-invocation: true
6
6
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: whats-happening
3
- description:
3
+ description: >-
4
4
  The desk for a Catalyst Cloud tenant. Use when the person asks "what's happening?", "where are we?", "why is that stuck?", "what closed?", "what's next?", or asks for something to be done rather than known. Reads the tenant contract, what is running and queued, the eligibility explainer and the open asks through the catalyst-skills CLI, and answers in one reply with ticket ids. Routes work to a project owner and decisions to what-needs-me. Never composes a URL, never polls, never answers as the human.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---