@catalyst-cloud/catalyst-skills 0.5.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.
- package/CHANGELOG.md +24 -0
- package/README.md +13 -9
- package/dist/args.js +16 -4
- package/dist/ask.js +6 -3
- package/dist/cli.js +9 -4
- package/dist/config.js +4 -0
- package/dist/contract.js +6 -0
- package/dist/environment.js +207 -0
- package/dist/execution.js +97 -15
- package/dist/pagination.js +76 -0
- package/dist/published.js +75 -0
- package/dist/query.js +41 -18
- package/dist/ready.js +110 -19
- package/dist/replica.js +295 -35
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +10 -0
- package/dist/skills.js +52 -1
- package/dist/ts-deps-loader.js +46 -1
- package/package.json +5 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
- package/skills/catalyst-onboard/SKILL.md +49 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/the-one-path.md +143 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
- package/skills/catalyst-setup/SKILL.md +2 -2
- package/skills/catalyst-setup/references/what-each-check-means.md +17 -5
- package/skills/connect-me/SKILL.md +1 -1
- package/skills/how-catalyst-works/SKILL.md +1 -1
- package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +4 -0
package/dist/replica.js
CHANGED
|
@@ -21,6 +21,47 @@ export function pidfilePath(dbPath) {
|
|
|
21
21
|
export function lockPath(dbPath) {
|
|
22
22
|
return `${dbPath}.writer.lock`;
|
|
23
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
|
+
}
|
|
24
65
|
export function pidAlive(pid) {
|
|
25
66
|
if (pid === null || !Number.isInteger(pid) || pid <= 0)
|
|
26
67
|
return false;
|
|
@@ -83,12 +124,14 @@ export function replicaStatus(ctx, cfg, opts = {}) {
|
|
|
83
124
|
pidfilePid: null,
|
|
84
125
|
writerAlive: false,
|
|
85
126
|
reasons: [],
|
|
127
|
+
writer: null,
|
|
86
128
|
};
|
|
87
129
|
if (!cfg)
|
|
88
130
|
return { ...base, reasons: ["not connected"] };
|
|
89
131
|
const dbPath = opts.dbPath ?? replicaDbPath(cfg, ctx.home);
|
|
132
|
+
const writer = readWriterState(dbPath);
|
|
90
133
|
if (!existsSync(dbPath))
|
|
91
|
-
return { ...base, verdict: "absent", exitCode: 3, dbPath, reasons: ["no replica file"] };
|
|
134
|
+
return { ...base, verdict: "absent", exitCode: 3, dbPath, reasons: ["no replica file"], writer };
|
|
92
135
|
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
|
93
136
|
const nowMs = opts.nowMs ?? ctx.now().getTime();
|
|
94
137
|
const lock = readLock(dbPath);
|
|
@@ -116,9 +159,10 @@ export function replicaStatus(ctx, cfg, opts = {}) {
|
|
|
116
159
|
pidfilePid,
|
|
117
160
|
writerAlive,
|
|
118
161
|
reasons,
|
|
162
|
+
writer,
|
|
119
163
|
};
|
|
120
164
|
}
|
|
121
|
-
|
|
165
|
+
function baseStatusLine(s) {
|
|
122
166
|
switch (s.verdict) {
|
|
123
167
|
case "not-configured":
|
|
124
168
|
return "replica: not configured — run login first";
|
|
@@ -130,6 +174,30 @@ export function statusLine(s) {
|
|
|
130
174
|
return `replica: stale at ${s.dbPath} (${s.reasons.join("; ")}${s.cursor !== null ? `; cursor ${s.cursor}` : ""}) — reads fall back to the API`;
|
|
131
175
|
}
|
|
132
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
|
+
}
|
|
133
201
|
/** better-sqlite3 when it resolves and constructs, else node:sqlite — with exactly one stderr line
|
|
134
202
|
* on the fallback so the choice is visible. */
|
|
135
203
|
export async function engineFor(sdk, dbPath, ctx, deps = {}) {
|
|
@@ -147,6 +215,54 @@ export async function engineFor(sdk, dbPath, ctx, deps = {}) {
|
|
|
147
215
|
return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
|
|
148
216
|
}
|
|
149
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
|
+
}
|
|
150
266
|
export async function cmdReplica(args, ctx, deps = {}) {
|
|
151
267
|
const [sub, ...rest] = positionals(args);
|
|
152
268
|
if (!sub)
|
|
@@ -201,41 +317,185 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
201
317
|
return 0;
|
|
202
318
|
}
|
|
203
319
|
const sdk = await loadSdk();
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
|
|
215
|
-
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;
|
|
216
330
|
});
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
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;
|
|
238
494
|
}
|
|
495
|
+
await eventSync?.stop();
|
|
496
|
+
releaseOwnPidfile(dbPath);
|
|
497
|
+
if (recordOwned)
|
|
498
|
+
clearWriterState(dbPath); // never a record this run had no right to
|
|
239
499
|
ctx.stdout("replica stopped");
|
|
240
500
|
return 0;
|
|
241
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
|
+
}
|
package/dist/skill-shape.js
CHANGED
|
@@ -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 {
|
|
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/ts-deps-loader.js
CHANGED
|
@@ -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
|
|
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
|
-
"description": "Customer skill bundle for Catalyst Cloud
|
|
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": {
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
"test": "vitest run",
|
|
40
40
|
"test:coverage": "vitest run --coverage",
|
|
41
41
|
"version:sync": "node scripts/sync-plugin-version.mjs",
|
|
42
|
-
"skill-lib:sync": "node scripts/sync-skill-lib.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"
|
|
43
45
|
},
|
|
44
46
|
"dependencies": {
|
|
45
47
|
"@catalyst-cloud/sdk": "^0.10.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
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
6
|
disable-model-invocation: true
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills — written in this repository for customer tenants -->
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.7.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# Catalyst Linear
|
|
11
11
|
|
|
@@ -48,3 +48,5 @@ Per-ticket execution history beyond the comments (the attempt ledger, the remedi
|
|
|
48
48
|
## Searching and listing
|
|
49
49
|
|
|
50
50
|
`node scripts/search.mjs <terms>` matches ticket identifiers and titles, PR titles, project and initiative names and returns a few of each. It is the only search; a list read with a filter is not a search and will hand back the ordinary first page, which reads as a false "not found". Lists (`catalyst-skills query issues --team <key>`, `query projects`, `query cycles`) are for a board view, not for finding one ticket.
|
|
51
|
+
|
|
52
|
+
A list verb now says when the cloud cut it short: without `--all`, `query issues`/`query pulls` print `truncated at N of M` on stderr the moment a scope holds more than one page. `query issues --all` and `query pulls --all` follow the cloud's page cursor to the end of the scope instead of stopping at the first page — use `--all` when the count, not just a sample, has to be right.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: catalyst-onboard
|
|
3
|
+
description: >-
|
|
4
|
+
Walk a person from nothing to their first Catalyst Cloud ticket running, one step at a time, hand-held. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", or when they have the skills installed and nothing else. Reads each part of the setup with the instrument that owns it — this machine, the person, the account, one project, one repository — never folding one into another, does every step a key can do through the catalyst-skills CLI, and for the steps only a browser can do hands over the exact page and says what to come back with. Never claims a step it did not watch succeed.
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
|
+
---
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.7.0 — written in this repository for customer tenants -->
|
|
9
|
+
|
|
10
|
+
# Onboard me
|
|
11
|
+
|
|
12
|
+
You take one person from "the skills are installed" to "a ticket is running on my own tenant". You do it **one step at a time**: do the step, show them what actually came back, say what it means and what is next, then stop and let them answer. You never print the whole ladder at them and you never batch several steps into one turn.
|
|
13
|
+
|
|
14
|
+
Setup has five parts and each is read by its own instrument: this **machine**, the **person**, the **account**, one **project** (a project is one Linear team), one **repository**. A failure in one is not a failure in another, and a failing part names who can fix it and where. Getting that wrong is the one mistake that wastes a person's afternoon: a project problem reported as a machine problem sends them retrying a local command that was never going to help.
|
|
15
|
+
|
|
16
|
+
## Run first
|
|
17
|
+
|
|
18
|
+
Scripts are run, never read. Each prints `--help`.
|
|
19
|
+
|
|
20
|
+
- `node scripts/where-am-i.mjs` — every part, each with the instrument that read it, its verdict, and for anything unfinished who can fix it and the page it is on. Works before the machine is connected; that is one of the states it reports.
|
|
21
|
+
- `node scripts/where-am-i.mjs --next` — the same reading, reduced to the single next step.
|
|
22
|
+
- `node scripts/where-am-i.mjs --json` — the same document for you to branch on.
|
|
23
|
+
|
|
24
|
+
Start every session with it, and run it again after every step the person completes. It is the only thing that decides where you are.
|
|
25
|
+
|
|
26
|
+
## Load on demand
|
|
27
|
+
|
|
28
|
+
| when | read |
|
|
29
|
+
| -- | -- |
|
|
30
|
+
| walking the steps — what each one does, what to read back, when it is done | `references/the-one-path.md` |
|
|
31
|
+
| anything reports not ready, or you are about to say who should fix something | `references/who-fixes-what.md` |
|
|
32
|
+
| the next step is a browser page, or a page said it worked and you have to confirm it | `references/what-the-browser-owns.md` |
|
|
33
|
+
| the person asks what Catalyst actually is, or how a ticket gets worked | the `how-catalyst-works` skill |
|
|
34
|
+
| the machine will not connect, or a login expired | the `connect-me` skill |
|
|
35
|
+
| setup is finished and they want the standing readiness verdict | the `catalyst-setup` skill |
|
|
36
|
+
| the first ticket did not start and you need the reason | the `how-catalyst-works` skill, then `unstick` |
|
|
37
|
+
|
|
38
|
+
## Rules
|
|
39
|
+
|
|
40
|
+
- **One step, then stop.** Say what you are about to do, do it, show the real output, say what it means and what comes next. Never queue several steps into one message, and never move on from a step you did not watch finish.
|
|
41
|
+
- **Report what you observed, not what you expected.** Print the lines the command actually produced. "That worked" without the output it produced is the single easiest thing to get wrong here, and a person who later finds it did not work stops trusting every other step you reported.
|
|
42
|
+
- **Each part by its own instrument.** Read the machine with the machine's instrument and the project with the project's, and label every finding with the part it belongs to. The script does this for you; keep it that way when you summarize.
|
|
43
|
+
- **Not ready is a question about who, not a reason to retry.** When something reports not ready, name which check, who can fix it, and where. If the owner is not the person in front of you, say so and stop — re-running a local command cannot move a check that belongs to a tenant owner, an admin, or a browser page.
|
|
44
|
+
- **Never invent a count or a list.** Every number and every name comes from what a command printed. If you want to tell them how many projects are mapped, read it off the script's output; do not carry one over from an earlier turn.
|
|
45
|
+
- **Three steps belong to a browser and always will**: approving the login, connecting Linear, and installing the GitHub App. Hand over the page and say what you need back. Do not claim you did them.
|
|
46
|
+
- **Some steps a key cannot do yet.** Listing every project, saving a stage mapping, adopting the workflow, registering a repository and declaring one repository's environment are settings-page work today; a key-callable path for them is being built. Route those through the browser and say plainly that it is a gap, not the design. Never guess at a route for them.
|
|
47
|
+
- **The account-wide environment declaration is the exception, and the one setup write you can perform.** `catalyst-skills environment` reads it, proposes it and approves it. Use the verb; do not send them to a page for it.
|
|
48
|
+
- **Their tenant, as them.** Everything goes through the CLI and the person's own login. You never name another tenant, and you never ask for a key you could avoid — the keyless login needs nothing pasted.
|
|
49
|
+
- **Stop at a wall you cannot pass.** A suspended account, a seat that is not active, a person who is not an owner or admin where one is required: say what you found, name who can act, and stop. Do not loop.
|