@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.
- package/CHANGELOG.md +36 -0
- package/README.md +19 -12
- package/dist/args.js +34 -4
- package/dist/ask.js +6 -3
- package/dist/cli.js +19 -5
- package/dist/config.js +4 -0
- package/dist/contract.js +6 -0
- package/dist/environment.js +207 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +146 -18
- 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/release.js +142 -0
- package/dist/replica.js +296 -30
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +10 -0
- package/dist/skills.js +52 -1
- package/dist/transport.js +5 -2
- package/dist/ts-deps-loader.js +46 -1
- package/package.json +7 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -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 +3 -3
- package/skills/catalyst-setup/references/what-each-check-means.md +22 -6
- package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
- package/skills/connect-me/SKILL.md +1 -1
- package/skills/connect-me/scripts/lib/cli.mjs +3 -2
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/SKILL.md +1 -1
- package/skills/how-catalyst-works/references/stages-and-mapping.md +2 -2
- package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/SKILL.md +2 -2
- package/skills/run-this-project/references/making-work-ready.md +1 -1
- package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
- package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
- package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
- package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- 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
|
-
|
|
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
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
}
|
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/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
|
-
|
|
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
|
-
|
|
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) {
|
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": {
|
|
@@ -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.
|
|
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:
|
|
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
|
|
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
|
+
}
|