@catalyst-cloud/catalyst-skills 0.4.0 → 0.7.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 (73) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +19 -12
  3. package/dist/args.js +34 -4
  4. package/dist/ask.js +6 -3
  5. package/dist/cli.js +19 -5
  6. package/dist/config.js +4 -0
  7. package/dist/contract.js +6 -0
  8. package/dist/environment.js +207 -0
  9. package/dist/events.js +106 -0
  10. package/dist/execution.js +146 -18
  11. package/dist/pagination.js +76 -0
  12. package/dist/published.js +75 -0
  13. package/dist/query.js +41 -18
  14. package/dist/ready.js +110 -19
  15. package/dist/release.js +142 -0
  16. package/dist/replica.js +296 -30
  17. package/dist/semver.js +18 -0
  18. package/dist/skill-shape.js +10 -0
  19. package/dist/skills.js +52 -1
  20. package/dist/transport.js +5 -2
  21. package/dist/ts-deps-loader.js +46 -1
  22. package/package.json +7 -4
  23. package/skills/catalyst-github/SKILL.md +1 -1
  24. package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
  25. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  26. package/skills/catalyst-linear/SKILL.md +1 -1
  27. package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
  28. package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
  29. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  30. package/skills/catalyst-onboard/SKILL.md +49 -0
  31. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  32. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  33. package/skills/catalyst-onboard/references/the-one-path.md +143 -0
  34. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  35. package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
  36. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  37. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  38. package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
  39. package/skills/catalyst-setup/SKILL.md +3 -3
  40. package/skills/catalyst-setup/references/what-each-check-means.md +22 -6
  41. package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
  42. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  43. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  44. package/skills/connect-me/SKILL.md +1 -1
  45. package/skills/connect-me/scripts/lib/cli.mjs +3 -2
  46. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  47. package/skills/how-catalyst-works/SKILL.md +1 -1
  48. package/skills/how-catalyst-works/references/stages-and-mapping.md +2 -2
  49. package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
  50. package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
  51. package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
  52. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  53. package/skills/run-this-project/SKILL.md +2 -2
  54. package/skills/run-this-project/references/making-work-ready.md +1 -1
  55. package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
  56. package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
  57. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  58. package/skills/unstick/SKILL.md +41 -0
  59. package/skills/unstick/agents/openai.yaml +6 -0
  60. package/skills/unstick/agents/portability.yaml +5 -0
  61. package/skills/unstick/references/playbook.md +51 -0
  62. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  63. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  64. package/skills/unstick/scripts/unstick.mjs +57 -0
  65. package/skills/what-needs-me/SKILL.md +1 -1
  66. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  67. package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
  68. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  69. package/skills/whats-happening/SKILL.md +2 -2
  70. package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
  71. package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
  72. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  73. package/skills/whats-happening/scripts/snapshot.mjs +4 -0
package/dist/replica.js CHANGED
@@ -13,6 +13,7 @@ import { CliError, UsageError } from "./errors.js";
13
13
  import { apiClient } from "./transport.js";
14
14
  import { authStrategyFor } from "./oauth.js";
15
15
  import { loadSdk } from "./sdk.js";
16
+ import { createEventSync } from "./events.js";
16
17
  export const DEFAULT_STALE_MS = 15_000;
17
18
  export function pidfilePath(dbPath) {
18
19
  return `${dbPath}.pid`;
@@ -20,6 +21,47 @@ export function pidfilePath(dbPath) {
20
21
  export function lockPath(dbPath) {
21
22
  return `${dbPath}.writer.lock`;
22
23
  }
24
+ export function writerStatePath(dbPath) {
25
+ return `${dbPath}.writer.state`;
26
+ }
27
+ /** Read the writer's state sidecar. A missing file, unparseable JSON, or a record with the wrong
28
+ * shape (a hand-edited or truncated file) all read as "no record", never a throw — readLock's
29
+ * contract, followed here. */
30
+ export function readWriterState(dbPath) {
31
+ try {
32
+ const rec = JSON.parse(readFileSync(writerStatePath(dbPath), "utf8"));
33
+ const stoppedOk = rec.stopped === null ||
34
+ (typeof rec.stopped === "object" &&
35
+ rec.stopped !== null &&
36
+ typeof rec.stopped.at === "number" &&
37
+ typeof rec.stopped.reason === "string" &&
38
+ typeof rec.stopped.restartWith === "string");
39
+ if (typeof rec.updatedAt !== "number" ||
40
+ typeof rec.pid !== "number" ||
41
+ typeof rec.consecutiveFailures !== "number" ||
42
+ !(rec.lastError === null || typeof rec.lastError === "string") ||
43
+ !(rec.lastFailureAt === null || typeof rec.lastFailureAt === "number") ||
44
+ !stoppedOk) {
45
+ return null;
46
+ }
47
+ return rec;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ export function writeWriterState(dbPath, s, nowMs) {
54
+ writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, updatedAt: nowMs }));
55
+ }
56
+ /** Remove the writer state sidecar; a no-op (never throws) when it is already gone. */
57
+ export function clearWriterState(dbPath) {
58
+ try {
59
+ unlinkSync(writerStatePath(dbPath));
60
+ }
61
+ catch {
62
+ // already gone
63
+ }
64
+ }
23
65
  export function pidAlive(pid) {
24
66
  if (pid === null || !Number.isInteger(pid) || pid <= 0)
25
67
  return false;
@@ -82,12 +124,14 @@ export function replicaStatus(ctx, cfg, opts = {}) {
82
124
  pidfilePid: null,
83
125
  writerAlive: false,
84
126
  reasons: [],
127
+ writer: null,
85
128
  };
86
129
  if (!cfg)
87
130
  return { ...base, reasons: ["not connected"] };
88
131
  const dbPath = opts.dbPath ?? replicaDbPath(cfg, ctx.home);
132
+ const writer = readWriterState(dbPath);
89
133
  if (!existsSync(dbPath))
90
- return { ...base, verdict: "absent", exitCode: 3, dbPath, reasons: ["no replica file"] };
134
+ return { ...base, verdict: "absent", exitCode: 3, dbPath, reasons: ["no replica file"], writer };
91
135
  const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
92
136
  const nowMs = opts.nowMs ?? ctx.now().getTime();
93
137
  const lock = readLock(dbPath);
@@ -115,9 +159,10 @@ export function replicaStatus(ctx, cfg, opts = {}) {
115
159
  pidfilePid,
116
160
  writerAlive,
117
161
  reasons,
162
+ writer,
118
163
  };
119
164
  }
120
- export function statusLine(s) {
165
+ function baseStatusLine(s) {
121
166
  switch (s.verdict) {
122
167
  case "not-configured":
123
168
  return "replica: not configured — run login first";
@@ -129,6 +174,30 @@ export function statusLine(s) {
129
174
  return `replica: stale at ${s.dbPath} (${s.reasons.join("; ")}${s.cursor !== null ? `; cursor ${s.cursor}` : ""}) — reads fall back to the API`;
130
175
  }
131
176
  }
177
+ /** Is a process that could still be writing this replica alive? The lock/pidfile liveness
178
+ * `replicaStatus` already computed, or the pid the writer stamped on its own record. Both renderers
179
+ * ask before describing that record in the present tense: the sidecar outlives a SIGKILL, a crash
180
+ * and a reboot, and "is backing off" about a dead process sends a customer off to wait for a retry
181
+ * that will never come (CTC-2499). */
182
+ export function writerIsRunning(s) {
183
+ return s.writerAlive || pidAlive(s.writer?.pid ?? null);
184
+ }
185
+ export function statusLine(s) {
186
+ const base = baseStatusLine(s);
187
+ const w = s.writer;
188
+ if (w?.stopped) {
189
+ return (`${base} — the writer stopped ${new Date(w.stopped.at).toISOString()}: ${w.stopped.reason} ` +
190
+ `(last error: ${w.lastError}); restart it with: ${w.stopped.restartWith}`);
191
+ }
192
+ if (w && w.consecutiveFailures > 0) {
193
+ if (!writerIsRunning(s)) {
194
+ return (`${base} — the writer recorded ${w.consecutiveFailures} failed snapshot pulls and is no longer running ` +
195
+ `(last error: ${w.lastError}); restart it with: ${REPLICA_RESTART_COMMAND}`);
196
+ }
197
+ return `${base} — the writer has failed ${w.consecutiveFailures} snapshot pulls in a row and is backing off (last error: ${w.lastError})`;
198
+ }
199
+ return base;
200
+ }
132
201
  /** better-sqlite3 when it resolves and constructs, else node:sqlite — with exactly one stderr line
133
202
  * on the fallback so the choice is visible. */
134
203
  export async function engineFor(sdk, dbPath, ctx, deps = {}) {
@@ -146,6 +215,54 @@ export async function engineFor(sdk, dbPath, ctx, deps = {}) {
146
215
  return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
147
216
  }
148
217
  }
218
+ export const SNAPSHOT_BASE_BACKOFF_MS = 30_000;
219
+ export const SNAPSHOT_MAX_BACKOFF_MS = 900_000; // 15 minutes
220
+ export const SNAPSHOT_MAX_FAILURES = 5;
221
+ /** How long a live session must hold before the supervisor calls the writer recovered. A warm boot
222
+ * emits no "resyncing", so a writer that recovered WITHOUT a re-seed has no transition to reset its
223
+ * count on; staying live is that signal. The failing warm loop never reaches it — its resync demand
224
+ * fails and tears the attempt down first (CTC-2499). */
225
+ export const SNAPSHOT_LIVE_STABLE_MS = 60_000;
226
+ export const REPLICA_RESTART_COMMAND = "catalyst-skills replica start --detach";
227
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
228
+ function defaultWaitForStop() {
229
+ return new Promise((resolve) => {
230
+ const done = () => resolve();
231
+ process.once("SIGINT", done);
232
+ process.once("SIGTERM", done);
233
+ });
234
+ }
235
+ /** Full jitter (AWS "Exponential Backoff and Jitter"): a uniform draw from [0, bound), where the
236
+ * bound doubles per consecutive failure and is clamped at the cap. */
237
+ export function backoffDelayMs(consecutiveFailures, opts) {
238
+ const bound = Math.min(opts.baseMs * 2 ** Math.max(0, consecutiveFailures - 1), opts.maxMs);
239
+ return Math.floor(opts.random() * bound);
240
+ }
241
+ function releaseOwnPidfile(dbPath) {
242
+ try {
243
+ if (readPidfile(dbPath) === process.pid)
244
+ unlinkSync(pidfilePath(dbPath));
245
+ }
246
+ catch {
247
+ // pidfile already gone
248
+ }
249
+ }
250
+ function messageOf(err) {
251
+ return err instanceof Error ? err.message : String(err);
252
+ }
253
+ /** Classify a `start()` rejection: null when it is an ordinary failed snapshot pull and the
254
+ * supervisor should count it, back off and retry. */
255
+ export function classifyStartError(err) {
256
+ const reason = messageOf(err);
257
+ if (err?.name === "ReplicaAccountMismatchError")
258
+ return { reason, recordable: false };
259
+ if (reason.includes("another writer owns this replica"))
260
+ return { reason, recordable: false };
261
+ // start()'s own lifecycle guards: a caller bug, never a tenant-side failure.
262
+ if (reason.includes("start() already called") || reason.includes("start() after close()"))
263
+ return { reason, recordable: false };
264
+ return null;
265
+ }
149
266
  export async function cmdReplica(args, ctx, deps = {}) {
150
267
  const [sub, ...rest] = positionals(args);
151
268
  if (!sub)
@@ -200,36 +317,185 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
200
317
  return 0;
201
318
  }
202
319
  const sdk = await loadSdk();
203
- const engine = await engineFor(sdk, dbPath, ctx, deps.engineDeps);
204
- const replica = new sdk.CatalystReplica({
205
- baseUrl: apiBase(cfg),
206
- account: cfg.account,
207
- accountSource: "declared",
208
- auth: authStrategyFor(ctx, cfg),
209
- dbPath,
210
- engine,
211
- fetchImpl: ctx.fetch,
212
- wsFactory: deps.wsFactory,
213
- log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
214
- onStatus: (s) => ctx.stderr(`[replica] status=${s}`),
320
+ const sleep = deps.sleep ?? defaultSleep;
321
+ const random = deps.random ?? Math.random;
322
+ const baseMs = deps.snapshotRetry?.baseBackoffMs ?? SNAPSHOT_BASE_BACKOFF_MS;
323
+ const maxMs = deps.snapshotRetry?.maxBackoffMs ?? SNAPSHOT_MAX_BACKOFF_MS;
324
+ const maxFailures = deps.snapshotRetry?.maxFailures ?? SNAPSHOT_MAX_FAILURES;
325
+ const liveStableMs = deps.snapshotRetry?.liveStableMs ?? SNAPSHOT_LIVE_STABLE_MS;
326
+ // Registered ONCE, not per attempt, so a supervised run does not pile up signal handlers.
327
+ let stopRequested = false;
328
+ const stopSignal = (deps.waitForStop ?? defaultWaitForStop)().then(() => {
329
+ stopRequested = true;
215
330
  });
216
- await replica.start();
217
- ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}) — Ctrl-C to stop`);
218
- const wait = deps.waitForStop ??
219
- (() => new Promise((resolve) => {
220
- const done = () => resolve();
221
- process.once("SIGINT", done);
222
- process.once("SIGTERM", done);
223
- }));
224
- await wait();
225
- await replica.close();
226
- try {
227
- if (readPidfile(dbPath) === process.pid)
228
- unlinkSync(pidfilePath(dbPath));
229
- }
230
- catch {
231
- // pidfile already gone
331
+ let eventSync = null;
332
+ let failures = 0;
333
+ /** A new run never inherits an old run's verdict — but it discards it only once it OWNS the
334
+ * replica. `start()` claims the writer lock before it does anything else, so an attempt that gets
335
+ * past that lock is ours; clearing up front deleted a LIVE writer's record whenever a second
336
+ * `replica start` lost the lock race (CTC-2499). */
337
+ let recordOwned = false;
338
+ const takeOwnershipOfRecord = () => {
339
+ if (recordOwned)
340
+ return;
341
+ recordOwned = true;
342
+ clearWriterState(dbPath);
343
+ };
344
+ const resetFailures = () => {
345
+ if (failures === 0)
346
+ return;
347
+ failures = 0;
348
+ writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: 0, lastError: null, lastFailureAt: null, stopped: null }, ctx.now().getTime());
349
+ };
350
+ for (;;) {
351
+ const engine = await engineFor(sdk, dbPath, ctx, deps.engineDeps);
352
+ let seedInFlight = false; // a /snapshot pull is running RIGHT NOW
353
+ let seedCompleted = false; // ...and the last one finished; what follows belongs to the socket
354
+ let attemptError = null;
355
+ // A box rather than a `let`: TypeScript narrows a captured `let` initialised to null down to
356
+ // `never`, and the supervisor below has to read what these callbacks wrote.
357
+ const fatal = { stop: null };
358
+ let signalFailure;
359
+ const failed = new Promise((res) => (signalFailure = res));
360
+ const replica = new sdk.CatalystReplica({
361
+ baseUrl: apiBase(cfg),
362
+ account: cfg.account,
363
+ accountSource: "declared",
364
+ auth: authStrategyFor(ctx, cfg),
365
+ dbPath,
366
+ engine,
367
+ fetchImpl: ctx.fetch,
368
+ wsFactory: deps.wsFactory,
369
+ log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
370
+ onStatus: (s) => {
371
+ ctx.stderr(`[replica] status=${s}`);
372
+ // A "live" that does NOT follow a re-seed is just the socket coming up — the warm failing
373
+ // loop passes through it every cycle, so it must not reset the count (CTC-2499).
374
+ if (s === "resyncing") {
375
+ seedInFlight = true;
376
+ seedCompleted = false;
377
+ return;
378
+ }
379
+ // openSocket() runs only after the seed has committed its cursor, so "connecting" is the
380
+ // SDK's one observable "the snapshot completed" edge. Everything after it is socket work: a
381
+ // blocked WebSocket upgrade is not a failed snapshot pull (CTC-2499).
382
+ if (s === "connecting") {
383
+ if (seedInFlight) {
384
+ seedInFlight = false;
385
+ seedCompleted = true;
386
+ }
387
+ return;
388
+ }
389
+ if (s === "live") {
390
+ if (seedCompleted) {
391
+ seedCompleted = false;
392
+ resetFailures();
393
+ }
394
+ return;
395
+ }
396
+ if ((s === "reconnecting" || s === "error") && seedInFlight && attemptError === null) {
397
+ seedInFlight = false;
398
+ attemptError = "the snapshot pull failed or ended early";
399
+ // SYNCHRONOUS inside onStatus: LiveSyncClient.stop() sets `stopped = true` as its first
400
+ // statement, and scheduleReconnect() — the very next statement in runResync() — returns
401
+ // early on it. This is what keeps the SDK from re-entering its own loop.
402
+ void replica.close().catch(() => { });
403
+ signalFailure();
404
+ return;
405
+ }
406
+ if (s === "auth-required" && attemptError === null) {
407
+ // A refused credential is not something to retry against the tenant at all: it is stopped
408
+ // and recorded, not counted towards maxFailures (CTC-2499).
409
+ seedInFlight = false;
410
+ seedCompleted = false;
411
+ attemptError = "the tenant refused this machine's credential on /snapshot";
412
+ fatal.stop = { reason: attemptError, recordable: true };
413
+ void replica.close().catch(() => { });
414
+ signalFailure();
415
+ }
416
+ },
417
+ });
418
+ // Always give start() a handler, so a rejection that loses the race is never unhandled.
419
+ const started = replica.start().then(() => "started", (err) => {
420
+ fatal.stop ??= classifyStartError(err);
421
+ attemptError ??= messageOf(err);
422
+ return "failed";
423
+ });
424
+ const outcome = await Promise.race([started, failed.then(() => "failed"), stopSignal.then(() => "stop")]);
425
+ // Something the SDK raised so the caller would STOP: retrying it is a hot loop against a wall,
426
+ // and two of the three fire while another process or another tenant owns this replica — so the
427
+ // record is left exactly as its owner wrote it (CTC-2499).
428
+ const stopNow = fatal.stop;
429
+ if (outcome === "failed" && stopNow !== null) {
430
+ await replica.close().catch(() => { });
431
+ await eventSync?.stop();
432
+ releaseOwnPidfile(dbPath);
433
+ if (stopNow.recordable) {
434
+ takeOwnershipOfRecord();
435
+ const nowMs = ctx.now().getTime();
436
+ writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: failures, lastError: stopNow.reason, lastFailureAt: nowMs, stopped: { at: nowMs, reason: stopNow.reason, restartWith: REPLICA_RESTART_COMMAND } }, nowMs);
437
+ }
438
+ ctx.stderr(`[replica] stopping without a retry: ${stopNow.reason}`);
439
+ ctx.stdout(`replica writer stopped without retrying: ${stopNow.reason} ` +
440
+ `The replica is optional — every read still works through the API.`);
441
+ return 1;
442
+ }
443
+ // Past the writer lock, so this run owns the replica and may discard a previous run's verdict.
444
+ // A "stop" outcome proves nothing — it can race the lock claim — so it takes no ownership.
445
+ if (outcome !== "stop")
446
+ takeOwnershipOfRecord();
447
+ if (outcome === "started") {
448
+ // The event cache is started once for the whole supervised run, not per attempt.
449
+ if (eventSync === null) {
450
+ eventSync = await createEventSync(ctx, { loadSdk: deps.loadEventsSdk });
451
+ void eventSync.start().catch((error) => ctx.stderr(`[events] sync failed: ${messageOf(error)}; replica remains live`));
452
+ }
453
+ ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}); event cache active — Ctrl-C to stop`);
454
+ // A warm boot never emits "resyncing", so a writer that recovered without a re-seed has no
455
+ // transition to reset its count on, and its stale count would both mis-report a healthy writer
456
+ // and accumulate NON-consecutive failures up to the stop threshold. A live session that holds
457
+ // for liveStableMs is that missing signal (CTC-2499).
458
+ if (failures > 0) {
459
+ const settled = await Promise.race([
460
+ failed.then(() => "failed"),
461
+ stopSignal.then(() => "stop"),
462
+ sleep(liveStableMs).then(() => "stable"),
463
+ ]);
464
+ if (settled === "stable")
465
+ resetFailures();
466
+ }
467
+ // start() resolves at the FIRST "live", which is the socket, not the seed — keep watching.
468
+ await Promise.race([failed, stopSignal]);
469
+ }
470
+ await replica.close().catch(() => { });
471
+ if (stopRequested)
472
+ break;
473
+ failures += 1;
474
+ const lastError = attemptError ?? "the snapshot pull failed";
475
+ const nowMs = ctx.now().getTime();
476
+ const stopped = failures >= maxFailures
477
+ ? { at: nowMs, reason: `${failures} consecutive snapshot failures`, restartWith: "catalyst-skills replica start --detach" }
478
+ : null;
479
+ writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: failures, lastError, lastFailureAt: nowMs, stopped }, nowMs);
480
+ if (stopped) {
481
+ await eventSync?.stop();
482
+ releaseOwnPidfile(dbPath);
483
+ ctx.stderr(`[replica] stopping: ${stopped.reason}; last error: ${lastError}`);
484
+ ctx.stdout(`replica writer stopped after ${failures} consecutive snapshot failures (last error: ${lastError}). ` +
485
+ `The replica is optional — every read still works through the API. ` +
486
+ `Restart it with: ${stopped.restartWith}`);
487
+ return 1;
488
+ }
489
+ const delay = backoffDelayMs(failures, { baseMs, maxMs, random });
490
+ ctx.stderr(`[replica] snapshot failure ${failures} of ${maxFailures}: ${lastError}; retrying in ${delay}ms`);
491
+ await Promise.race([sleep(delay), stopSignal]);
492
+ if (stopRequested)
493
+ break;
232
494
  }
495
+ await eventSync?.stop();
496
+ releaseOwnPidfile(dbPath);
497
+ if (recordOwned)
498
+ clearWriterState(dbPath); // never a record this run had no right to
233
499
  ctx.stdout("replica stopped");
234
500
  return 0;
235
501
  }
package/dist/semver.js ADDED
@@ -0,0 +1,18 @@
1
+ // semver.ts — the one three-field comparator every version-drift check in this package shares
2
+ // (the tenant-minimum bundle check, and CTC-2160's published-release checks).
3
+ /** True when `a` is a semver-older release than `b`. Prerelease/build metadata is ignored; a segment
4
+ * that does not parse counts as 0. A tiny compare on purpose — no dependency for three fields. */
5
+ export function semverOlder(a, b) {
6
+ const parse = (v) => {
7
+ const core = v.split("-")[0].split("+")[0];
8
+ const p = core.split(".");
9
+ return [Number(p[0]) || 0, Number(p[1]) || 0, Number(p[2]) || 0];
10
+ };
11
+ const [a0, a1, a2] = parse(a);
12
+ const [b0, b1, b2] = parse(b);
13
+ if (a0 !== b0)
14
+ return a0 < b0;
15
+ if (a1 !== b1)
16
+ return a1 < b1;
17
+ return a2 < b2;
18
+ }
@@ -7,6 +7,16 @@ export const PROVENANCE_MARKER = "vendored-from: @catalyst-cloud/catalyst-skills
7
7
  export const MAX_SKILL_LINES = 80;
8
8
  export const MAX_REFERENCE_LINES = 150;
9
9
  export const MAX_DESCRIPTION_CHARS = 1024;
10
+ /** The first release whose SKILL.md files carry a version on the provenance line. An installed
11
+ * skill with the marker and no version predates this release — but that only PROVES staleness
12
+ * once a release at or above it has actually been published (CTC-2160 D4). */
13
+ export const FIRST_STAMPED_VERSION = "0.7.0";
14
+ const PROVENANCE_VERSION_RE = new RegExp(`${PROVENANCE_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@(\\S+)`);
15
+ /** The version stamped on a provenance comment line, or null when the line carries none. */
16
+ export function parseProvenanceVersion(line) {
17
+ const m = PROVENANCE_VERSION_RE.exec(line);
18
+ return m ? m[1] : null;
19
+ }
10
20
  /** Strings no customer-facing skill file may contain. */
11
21
  export const FORBIDDEN_CONTENT = [
12
22
  { name: "the maintainer tenant (tenant-0)", re: /tenant-0/ },
package/dist/skills.js CHANGED
@@ -4,7 +4,8 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } fr
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { PACKAGE_NAME, defaultSkillsDirFor } from "./config.js";
7
- import { PROVENANCE_MARKER } from "./skill-shape.js";
7
+ import { semverOlder } from "./semver.js";
8
+ import { PROVENANCE_MARKER, parseFrontmatter, parseProvenanceVersion } from "./skill-shape.js";
8
9
  export function skillsSourceDir() {
9
10
  return fileURLToPath(new URL("../skills", import.meta.url));
10
11
  }
@@ -49,6 +50,56 @@ export function installSkills(targetDir, opts, sourceDir = skillsSourceDir()) {
49
50
  export function resolveSkillsDir(args, ctx, cfg) {
50
51
  return args.skillsDir ?? ctx.env.CATALYST_SKILLS_CLAUDE_DIR ?? cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
51
52
  }
53
+ /** The provenance comment in a SKILL.md, wherever that file's frontmatter actually ends.
54
+ *
55
+ * `validateSkillDir` requires the comment on the line right after the closing fence and caps the
56
+ * FILE's length, never the frontmatter's — so a fixed head window was an unpinned assumption, and a
57
+ * skill with a long wrapped `description` fell past it and became invisible: no version and not even
58
+ * counted as unstamped, silently dropping out of the staleness check. A file whose shape is off
59
+ * falls back to the whole-file scan `installSkills` already uses to recognise our own copies. */
60
+ function provenanceLine(text) {
61
+ const lines = text.split("\n");
62
+ const fm = parseFrontmatter(lines);
63
+ if (fm && fm.closeIndex !== -1) {
64
+ const after = lines[fm.closeIndex + 1];
65
+ if (after?.includes(PROVENANCE_MARKER))
66
+ return after;
67
+ }
68
+ return lines.find((l) => l.includes(PROVENANCE_MARKER));
69
+ }
70
+ /** What skill-bundle version is actually sitting in `dir`. Reads only the provenance comment; a
71
+ * directory this package did not write (no marker) is ignored, not counted as stale, and a name
72
+ * that is not installed at all is skipped. */
73
+ export function installedBundleVersion(dir, names) {
74
+ let version = null;
75
+ let skill = null;
76
+ const unstamped = [];
77
+ for (const name of names) {
78
+ const skillMd = join(dir, name, "SKILL.md");
79
+ if (!existsSync(skillMd))
80
+ continue;
81
+ let text;
82
+ try {
83
+ text = readFileSync(skillMd, "utf8");
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ const line = provenanceLine(text);
89
+ if (line === undefined)
90
+ continue;
91
+ const stamp = parseProvenanceVersion(line);
92
+ if (stamp === null) {
93
+ unstamped.push(name);
94
+ continue;
95
+ }
96
+ if (version === null || semverOlder(stamp, version)) {
97
+ version = stamp;
98
+ skill = name;
99
+ }
100
+ }
101
+ return { version, skill, unstamped };
102
+ }
52
103
  export function parseChangelogEntry(changelog, version) {
53
104
  const lines = changelog.split("\n");
54
105
  const heading = `## ${version}`;
package/dist/transport.js CHANGED
@@ -70,7 +70,9 @@ export class ApiClient {
70
70
  }
71
71
  return { status: res.status, body: rows, headers: res.headers };
72
72
  }
73
- async postJson(path, body) {
73
+ /** `opts.accept` lists non-2xx statuses returned to the caller with their JSON body instead of thrown
74
+ * — the release route answers a refusal as 409 with the refusals the caller has to print. */
75
+ async postJson(path, body, opts = {}) {
74
76
  const url = this.url(path);
75
77
  const res = await this.send(url, {
76
78
  method: "POST",
@@ -81,7 +83,8 @@ export class ApiClient {
81
83
  },
82
84
  body: JSON.stringify(body),
83
85
  });
84
- await this.refuseIfNotOk(res, `POST ${path}`);
86
+ if (!opts.accept?.includes(res.status))
87
+ await this.refuseIfNotOk(res, `POST ${path}`);
85
88
  return { status: res.status, body: await this.parseBody(res, `POST ${path}`), headers: res.headers };
86
89
  }
87
90
  async send(url, init) {
@@ -17,6 +17,51 @@ let installed = false;
17
17
  function inDeps(url) {
18
18
  return typeof url === "string" && url.startsWith("file:") && url.includes("/node_modules/");
19
19
  }
20
+ /** Both modes this runtime offers refused the source. Naming the file and both refusals beats
21
+ * surfacing Node's raw `options.mode` message, which names the mode Node rejected rather than the
22
+ * syntax in the SOURCE that made both modes fail. */
23
+ export class StripModeError extends Error {
24
+ sourceUrl;
25
+ refusals;
26
+ constructor(sourceUrl, refusals) {
27
+ super(`${sourceUrl}: could not be type-stripped by any mode this runtime offers. This usually means ` +
28
+ `the source uses TypeScript syntax with runtime semantics (an enum, a runtime namespace, a ` +
29
+ `parameter property, or \`export =\`) that Node's type-stripping can never erase, on a ` +
30
+ `runtime that also refuses \`transform\`. ` +
31
+ `strip refused: ${refusals.strip} | transform refused: ${refusals.transform}`);
32
+ this.name = "StripModeError";
33
+ this.sourceUrl = sourceUrl;
34
+ this.refusals = refusals;
35
+ }
36
+ }
37
+ function messageOf(error) {
38
+ return error instanceof Error ? error.message : String(error);
39
+ }
40
+ /** CTC-2483 — strip types preferring the one mode EVERY supported Node accepts.
41
+ *
42
+ * ⭐ `strip` is always tried first, and that ordering is the whole fix. Node 26's
43
+ * `stripTypeScriptTypes` accepts only `{ mode: "strip" }` and throws ERR_INVALID_ARG_VALUE for
44
+ * `"transform"`, so a loader that reaches for `transform` first dies there before it ever learns
45
+ * whether `strip` would have worked. But `strip` is not a weaker `transform`: it REFUSES
46
+ * TypeScript syntax with runtime semantics (enums, runtime namespaces, parameter properties,
47
+ * `export =`) that `transform` erases, so `transform` stays reachable as a fallback on Node 22-25
48
+ * for sources that need it. */
49
+ export function stripPreferringStrip(strip, source, sourceUrl) {
50
+ try {
51
+ return strip(source, { mode: "strip", sourceUrl });
52
+ }
53
+ catch (stripError) {
54
+ try {
55
+ return strip(source, { mode: "transform", sourceUrl });
56
+ }
57
+ catch (transformError) {
58
+ throw new StripModeError(sourceUrl, {
59
+ strip: messageOf(stripError),
60
+ transform: messageOf(transformError),
61
+ });
62
+ }
63
+ }
64
+ }
20
65
  /** Build the hook pair over a strip function. Pure, so a test can drive it with fake `next`s. */
21
66
  export function makeHooks(strip, fileExists = existsSync, readFile = (p) => readFileSync(p, "utf8")) {
22
67
  return {
@@ -37,7 +82,7 @@ export function makeHooks(strip, fileExists = existsSync, readFile = (p) => read
37
82
  },
38
83
  load(url, context, next) {
39
84
  if (inDeps(url) && url.endsWith(".ts")) {
40
- return { format: "module", shortCircuit: true, source: strip(readFile(fileURLToPath(url)), { mode: "transform", sourceUrl: url }) };
85
+ return { format: "module", shortCircuit: true, source: stripPreferringStrip(strip, readFile(fileURLToPath(url)), url) };
41
86
  }
42
87
  return next(url, context);
43
88
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/catalyst-skills",
3
- "version": "0.4.0",
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.",
3
+ "version": "0.7.0",
4
+ "description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat — get set up, see what is happening, see what needs you, run a project, unstick what is stuck — plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command. The skills directory is the roster; this line never counts it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -38,10 +38,13 @@
38
38
  "prepare": "tsc -p tsconfig.build.json",
39
39
  "test": "vitest run",
40
40
  "test:coverage": "vitest run --coverage",
41
- "version:sync": "node scripts/sync-plugin-version.mjs"
41
+ "version:sync": "node scripts/sync-plugin-version.mjs",
42
+ "skill-lib:sync": "node scripts/sync-skill-lib.mjs",
43
+ "skills:scan": "node scripts/scan-skills.mjs",
44
+ "evals": "node scripts/run-evals.mjs"
42
45
  },
43
46
  "dependencies": {
44
- "@catalyst-cloud/sdk": "^0.9.0"
47
+ "@catalyst-cloud/sdk": "^0.10.0"
45
48
  },
46
49
  "devDependencies": {
47
50
  "@types/node": "^26.3.0",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.7.0 — written in this repository for customer tenants -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -8,9 +8,10 @@ import { spawnSync } from "node:child_process";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
11
12
 
12
13
  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=<your personal key> npx ${PACKAGE} login`;
14
+ export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
14
15
 
15
16
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
16
17
  export function configPath() {
@@ -32,7 +33,7 @@ export function requireCustomerConfig() {
32
33
  console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
33
34
  process.exit(2);
34
35
  }
35
- if (!cfg || typeof cfg !== "object" || typeof cfg.key !== "string" || typeof cfg.baseUrl !== "string") {
36
+ if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
36
37
  console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
37
38
  process.exit(2);
38
39
  }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }