@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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// pagination.ts — the mirror's keyset contract, followed. `/issues` and `/pulls` answer a page plus
|
|
2
|
+
// three headers: X-Mirror-Cursor (the head seq the page is consistent with), X-Mirror-Total (the
|
|
3
|
+
// WHOLE scope's count, independent of the page) and X-Mirror-Next-Cursor — present ONLY while rows
|
|
4
|
+
// remain, ABSENT on the last page. A caller that ignores them gets a page and believes it is the
|
|
5
|
+
// answer, which is the whole of CTC-2010.
|
|
6
|
+
//
|
|
7
|
+
// The SDK's createTenantClient parses these same three headers into a typed KeysetPageMeta, and this
|
|
8
|
+
// module deliberately does not use it: that client takes a STATIC `key`, while this bundle resolves
|
|
9
|
+
// its bearer fresh per request through `bearerFor` (an OAuth session refreshes mid-walk), and `ask`
|
|
10
|
+
// must keep working on a runtime where the SDK cannot load (src/sdk.ts). Adopting it is the CTC-2004
|
|
11
|
+
// Tier 2 migration that also deletes transport.ts — not this change.
|
|
12
|
+
import { CliError } from "./errors.js";
|
|
13
|
+
export const NEXT_CURSOR_HEADER = "x-mirror-next-cursor";
|
|
14
|
+
export const TOTAL_HEADER = "x-mirror-total";
|
|
15
|
+
/** The server clamps every page to this regardless of the requested `limit` (MAX_VIEW_LIMIT). */
|
|
16
|
+
export const SERVER_PAGE_CAP = 500;
|
|
17
|
+
/** 100 pages × 500 rows = 50,000. Reaching it is a refusal, not an ending. */
|
|
18
|
+
export const MAX_PAGES = 100;
|
|
19
|
+
/** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
|
|
20
|
+
export function rowsOf(body) {
|
|
21
|
+
if (Array.isArray(body))
|
|
22
|
+
return body;
|
|
23
|
+
if (body && typeof body === "object") {
|
|
24
|
+
for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
|
|
25
|
+
const v = body[key];
|
|
26
|
+
if (Array.isArray(v))
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
function readTotal(headers) {
|
|
33
|
+
const raw = headers.get(TOTAL_HEADER);
|
|
34
|
+
if (raw === null)
|
|
35
|
+
return null;
|
|
36
|
+
const n = Number(raw);
|
|
37
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
38
|
+
}
|
|
39
|
+
export async function fetchPage(api, path, query) {
|
|
40
|
+
const res = await api.getJson(path, { query });
|
|
41
|
+
const next = res.headers.get(NEXT_CURSOR_HEADER);
|
|
42
|
+
return {
|
|
43
|
+
rows: rowsOf(res.body),
|
|
44
|
+
nextCursor: next === null || next === "" ? null : next,
|
|
45
|
+
total: readTotal(res.headers),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function fetchAllPages(api, path, query) {
|
|
49
|
+
const rows = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
let after;
|
|
52
|
+
let total = null;
|
|
53
|
+
for (let pages = 1; pages <= MAX_PAGES; pages++) {
|
|
54
|
+
const page = await fetchPage(api, path, { ...query, limit: SERVER_PAGE_CAP, after });
|
|
55
|
+
rows.push(...page.rows);
|
|
56
|
+
if (page.total !== null)
|
|
57
|
+
total = page.total;
|
|
58
|
+
if (page.nextCursor === null)
|
|
59
|
+
return { rows, total, pages };
|
|
60
|
+
// ⛔ A cursor that does not advance is an infinite loop, not a long read.
|
|
61
|
+
if (seen.has(page.nextCursor)) {
|
|
62
|
+
throw new CliError(`GET ${path} repeated a page cursor after ${rows.length} rows — the cloud is not advancing the keyset, so --all cannot finish`, "pagination-stalled", 1);
|
|
63
|
+
}
|
|
64
|
+
seen.add(page.nextCursor);
|
|
65
|
+
after = page.nextCursor;
|
|
66
|
+
}
|
|
67
|
+
throw new CliError(`GET ${path} still had pages after ${MAX_PAGES} of them (${rows.length} rows) — narrow the scope with --team/--project/--state, or read this through the replica`, "pagination-overrun", 1);
|
|
68
|
+
}
|
|
69
|
+
/** ⛔ N IS WHAT THE SERVER SENT, not what a client-side filter left. `--team ZZZ` over a capped page
|
|
70
|
+
* would otherwise read "truncated at 0 of 120", which names the filter, not the truncation. */
|
|
71
|
+
export function truncationNotice(page) {
|
|
72
|
+
if (page.nextCursor === null)
|
|
73
|
+
return null;
|
|
74
|
+
const of = page.total === null ? `an unknown total (the cloud sent no ${TOTAL_HEADER})` : String(page.total);
|
|
75
|
+
return `truncated at ${page.rows.length} of ${of} — re-run with --all to read the whole scope`;
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// published.ts — "what is the newest release of this package?", read from the npm registry. This is
|
|
2
|
+
// the ONLY non-tenant host this package contacts. `ready` has always been an offline command, so the
|
|
3
|
+
// answer is cached for six hours, the request is capped at 1500ms, and EVERY failure returns a
|
|
4
|
+
// reason instead of throwing: a customer who cannot reach npm must still get a verdict (CTC-2160).
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { PACKAGE_NAME, publishedCachePathFor } from "./config.js";
|
|
8
|
+
export const REGISTRY_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
|
|
9
|
+
export const PUBLISHED_TTL_SECONDS = 6 * 60 * 60;
|
|
10
|
+
export const PUBLISHED_TIMEOUT_MS = 1500;
|
|
11
|
+
function readPublishedCache(home) {
|
|
12
|
+
const path = publishedCachePathFor(home);
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
if (typeof parsed.fetchedAt !== "string" || typeof parsed.latest !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** A write failure (read-only home) must not break the lookup — best-effort only. */
|
|
26
|
+
function writePublishedCache(home, cache) {
|
|
27
|
+
try {
|
|
28
|
+
const path = publishedCachePathFor(home);
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
const tmp = `${path}.tmp`;
|
|
31
|
+
writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// best-effort: a stale or missing cache next time is not a new failure mode
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function fallback(cached, reason) {
|
|
39
|
+
return cached ? { latest: cached.latest, reason: null, source: "stale-cache" } : { latest: null, reason, source: "none" };
|
|
40
|
+
}
|
|
41
|
+
export async function latestPublishedVersion(ctx, opts = {}) {
|
|
42
|
+
const cached = readPublishedCache(ctx.home);
|
|
43
|
+
const ageSeconds = cached ? Math.max(0, Math.floor((ctx.now().getTime() - Date.parse(cached.fetchedAt)) / 1000)) : Infinity;
|
|
44
|
+
if (cached && ageSeconds < PUBLISHED_TTL_SECONDS) {
|
|
45
|
+
return { latest: cached.latest, reason: null, source: "cache" };
|
|
46
|
+
}
|
|
47
|
+
if (opts.offline) {
|
|
48
|
+
return cached ? { latest: cached.latest, reason: null, source: "cache" } : { latest: null, reason: "offline", source: "none" };
|
|
49
|
+
}
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await ctx.fetch(REGISTRY_DIST_TAGS_URL, {
|
|
53
|
+
signal: AbortSignal.timeout(PUBLISHED_TIMEOUT_MS),
|
|
54
|
+
headers: { accept: "application/json" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const reason = err instanceof Error && err.name === "TimeoutError" ? "timed out" : err instanceof Error ? err.message : String(err);
|
|
59
|
+
return fallback(cached, reason);
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
return fallback(cached, `registry answered ${res.status}`);
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = await res.json();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return fallback(cached, "the registry sent an unreadable body");
|
|
69
|
+
}
|
|
70
|
+
const latest = body?.latest;
|
|
71
|
+
if (typeof latest !== "string")
|
|
72
|
+
return fallback(cached, "the registry sent no dist-tags.latest");
|
|
73
|
+
writePublishedCache(ctx.home, { fetchedAt: ctx.now().toISOString(), latest });
|
|
74
|
+
return { latest, reason: null, source: "network" };
|
|
75
|
+
}
|
package/dist/query.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
// fresh, else the API, with the source named on stderr every time. Output shape is the read-model
|
|
3
3
|
// view either way. `pull`, `cycles`, `search` and `changes` are API-only (the SDK wraps no view for
|
|
4
4
|
// them and the read-model package is not a declared dependency of this bundle).
|
|
5
|
-
import { flagInt, flagString, positionals } from "./args.js";
|
|
5
|
+
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
6
6
|
import { requireConfig, replicaDbPath } from "./config.js";
|
|
7
7
|
import { CliError, UsageError } from "./errors.js";
|
|
8
8
|
import { apiClient } from "./transport.js";
|
|
9
|
+
import { fetchAllPages, fetchPage, rowsOf, truncationNotice } from "./pagination.js";
|
|
9
10
|
import { engineFor, replicaStatus } from "./replica.js";
|
|
10
11
|
import { loadSdk } from "./sdk.js";
|
|
12
|
+
export { rowsOf };
|
|
11
13
|
const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
|
|
14
|
+
/** The two keyset routes `--all` can follow. `projects`/`cycles`/`search` are offset-paged or
|
|
15
|
+
* unpaginated and send no cursor header. */
|
|
16
|
+
const PAGINATED = new Set(["issues", "pulls"]);
|
|
12
17
|
export async function cmdQuery(args, ctx, deps = {}) {
|
|
13
18
|
const [sub, ...rest] = positionals(args);
|
|
14
19
|
if (!sub)
|
|
@@ -19,10 +24,20 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
19
24
|
project: flagString(args, "project"),
|
|
20
25
|
state: flagString(args, "state"),
|
|
21
26
|
limit: flagInt(args, "limit", 50),
|
|
27
|
+
all: flagBool(args, "all"),
|
|
22
28
|
};
|
|
23
29
|
const forced = flagString(args, "source");
|
|
24
30
|
if (forced && forced !== "replica" && forced !== "api")
|
|
25
31
|
throw new UsageError("--source must be replica or api");
|
|
32
|
+
if (filters.all && !PAGINATED.has(sub)) {
|
|
33
|
+
throw new UsageError(`--all follows the cloud's keyset pages, which only ${[...PAGINATED].join(" and ")} have`);
|
|
34
|
+
}
|
|
35
|
+
if (filters.all && forced === "replica") {
|
|
36
|
+
throw new UsageError("--all follows the cloud's page cursor; --source replica reads a local mirror that has none. Pick one.");
|
|
37
|
+
}
|
|
38
|
+
if (filters.all && flagString(args, "limit") !== undefined) {
|
|
39
|
+
ctx.stderr("--all reads the whole scope, so --limit is ignored");
|
|
40
|
+
}
|
|
26
41
|
const status = replicaStatus(ctx, cfg);
|
|
27
42
|
let source;
|
|
28
43
|
let why;
|
|
@@ -34,6 +49,10 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
34
49
|
source = forced;
|
|
35
50
|
why = `--source ${forced}`;
|
|
36
51
|
}
|
|
52
|
+
else if (filters.all) {
|
|
53
|
+
source = "api";
|
|
54
|
+
why = "--all follows the cloud's pages";
|
|
55
|
+
}
|
|
37
56
|
else if (status.verdict === "fresh") {
|
|
38
57
|
source = "replica";
|
|
39
58
|
why = `cursor ${status.cursor}`;
|
|
@@ -90,8 +109,16 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
90
109
|
const common = { team: f.team, project: f.project, state: f.state, limit: f.limit };
|
|
91
110
|
switch (sub) {
|
|
92
111
|
case "issues": {
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
if (f.all) {
|
|
113
|
+
const { rows } = await fetchAllPages(api, "/api/v1/issues", { team: f.team, project: f.project, state: f.state });
|
|
114
|
+
return applyFilters(rows, f); // no .slice — --all means the whole scope
|
|
115
|
+
}
|
|
116
|
+
const page = await fetchPage(api, "/api/v1/issues", common);
|
|
117
|
+
const rows = applyFilters(page.rows, f).slice(0, f.limit);
|
|
118
|
+
const notice = truncationNotice(page);
|
|
119
|
+
if (notice)
|
|
120
|
+
ctx.stderr(notice);
|
|
121
|
+
return rows;
|
|
95
122
|
}
|
|
96
123
|
case "issue": {
|
|
97
124
|
const id = needArg(rest, "issue <identifier>");
|
|
@@ -99,8 +126,17 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
99
126
|
return res.status === 404 ? null : res.body;
|
|
100
127
|
}
|
|
101
128
|
case "pulls": {
|
|
102
|
-
const
|
|
103
|
-
|
|
129
|
+
const ticket = flagString(args, "ticket");
|
|
130
|
+
if (f.all) {
|
|
131
|
+
const { rows } = await fetchAllPages(api, "/api/v1/pulls", { ticket });
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
const page = await fetchPage(api, "/api/v1/pulls", { limit: f.limit, ticket });
|
|
135
|
+
const rows = page.rows.slice(0, f.limit);
|
|
136
|
+
const notice = truncationNotice(page);
|
|
137
|
+
if (notice)
|
|
138
|
+
ctx.stderr(notice);
|
|
139
|
+
return rows;
|
|
104
140
|
}
|
|
105
141
|
case "pull": {
|
|
106
142
|
const id = needArg(rest, "pull <node id>");
|
|
@@ -177,19 +213,6 @@ function needArg(rest, usage) {
|
|
|
177
213
|
throw new UsageError(`query ${usage}`);
|
|
178
214
|
return v;
|
|
179
215
|
}
|
|
180
|
-
/** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
|
|
181
|
-
export function rowsOf(body) {
|
|
182
|
-
if (Array.isArray(body))
|
|
183
|
-
return body;
|
|
184
|
-
if (body && typeof body === "object") {
|
|
185
|
-
for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
|
|
186
|
-
const v = body[key];
|
|
187
|
-
if (Array.isArray(v))
|
|
188
|
-
return v;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
216
|
export function applyFilters(rows, f) {
|
|
194
217
|
return rows.filter((r) => {
|
|
195
218
|
if (f.team && !(String(r.identifier ?? "").toUpperCase().startsWith(`${f.team.toUpperCase()}-`) || r.team_id === f.team || r.team_key === f.team))
|
package/dist/ready.js
CHANGED
|
@@ -6,23 +6,42 @@ import { join } from "node:path";
|
|
|
6
6
|
import { defaultSkillsDirFor, loadConfig, readManifest } from "./config.js";
|
|
7
7
|
import { contractVersionInRange, readContractCache } from "./contract.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
9
|
-
import {
|
|
9
|
+
import { latestPublishedVersion } from "./published.js";
|
|
10
|
+
import { replicaStatus, writerIsRunning } from "./replica.js";
|
|
10
11
|
import { loadSdk } from "./sdk.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
12
|
+
import { semverOlder } from "./semver.js";
|
|
13
|
+
import { FIRST_STAMPED_VERSION } from "./skill-shape.js";
|
|
14
|
+
import { installedBundleVersion } from "./skills.js";
|
|
15
|
+
export { semverOlder };
|
|
16
|
+
/** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
|
|
17
|
+
* mirror's snapshot path is safe for a large tenant, a customer following that advice is the
|
|
18
|
+
* failure mode. It DOES name the restart command when the writer gave up, because that is an
|
|
19
|
+
* instruction about a thing already running, not a nudge to adopt one (CTC-2499). */
|
|
20
|
+
function readyReplicaLine(s) {
|
|
21
|
+
const w = s.writer;
|
|
22
|
+
if (w?.stopped) {
|
|
23
|
+
return (`replica: the writer stopped ${new Date(w.stopped.at).toISOString()}: ${w.stopped.reason} ` +
|
|
24
|
+
`(last error: ${w.lastError}) — the replica is optional and every read ` +
|
|
25
|
+
`still works through the API; restart it with: ${w.stopped.restartWith}`);
|
|
26
|
+
}
|
|
27
|
+
if (w && w.consecutiveFailures > 0) {
|
|
28
|
+
// Present tense only for a writer that is actually running: the record survives a SIGKILL, and
|
|
29
|
+
// "is backing off" about a dead process promises a retry that will never come (CTC-2499).
|
|
30
|
+
if (!writerIsRunning(s)) {
|
|
31
|
+
return `replica: the writer recorded ${w.consecutiveFailures} failed snapshot pulls and is no longer running (last error: ${w.lastError}) — the replica is optional and every read still works through the API`;
|
|
32
|
+
}
|
|
33
|
+
return `replica: the writer has failed ${w.consecutiveFailures} snapshot pulls in a row and is backing off (last error: ${w.lastError}) — reads fall back to the API`;
|
|
34
|
+
}
|
|
35
|
+
switch (s.verdict) {
|
|
36
|
+
case "not-configured":
|
|
37
|
+
return "replica: not configured — run login first";
|
|
38
|
+
case "absent":
|
|
39
|
+
return `replica: absent at ${s.dbPath} — optional, and off by default for large tenants while the snapshot path is being made safe; every read works through the API`;
|
|
40
|
+
case "fresh":
|
|
41
|
+
return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
|
|
42
|
+
case "stale":
|
|
43
|
+
return `replica: stale at ${s.dbPath} (${s.reasons.join("; ")}${s.cursor !== null ? `; cursor ${s.cursor}` : ""}) — reads fall back to the API`;
|
|
44
|
+
}
|
|
26
45
|
}
|
|
27
46
|
function whoCanAnswer(doc) {
|
|
28
47
|
const roles = doc.humans.map((h) => `${h.role} ${h.linearUserId}`);
|
|
@@ -79,6 +98,61 @@ export async function readyReport(ctx, deps) {
|
|
|
79
98
|
});
|
|
80
99
|
}
|
|
81
100
|
}
|
|
101
|
+
// CTC-2160 — the two artefacts a customer installs drift apart: the CLI comes from npm, the skill
|
|
102
|
+
// files come from `npx skills add` (GitHub) or the plugin, and only the CLI's own version was ever
|
|
103
|
+
// knowable here. Both are NOTES: being behind a publish is a degrading fact about an install that
|
|
104
|
+
// still works, so `ready` stays READY. This is the one place `ready` touches the network, and it is
|
|
105
|
+
// capped, cached and skippable — see src/published.ts.
|
|
106
|
+
const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
|
|
107
|
+
const offline = deps.offline === true || ctx.env.CATALYST_SKILLS_OFFLINE === "1";
|
|
108
|
+
if (!offline) {
|
|
109
|
+
const pub = await (deps.fetchLatestRelease ?? (() => latestPublishedVersion(ctx)))();
|
|
110
|
+
if (pub.latest === null) {
|
|
111
|
+
checks.push({
|
|
112
|
+
id: "cliRelease",
|
|
113
|
+
ok: false,
|
|
114
|
+
note: true,
|
|
115
|
+
line: `cliRelease: could not check for a newer release (${pub.reason}) — nothing about this install is known to be wrong; re-run when the network is back`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// clause 2 — the CLI. Suppressed when the tenant-minimum note already named this binary (D9).
|
|
120
|
+
const installedCli = readManifest().version;
|
|
121
|
+
const bundleNoteFired = checks.some((c) => c.id === "bundle");
|
|
122
|
+
if (!bundleNoteFired && semverOlder(installedCli, pub.latest)) {
|
|
123
|
+
checks.push({
|
|
124
|
+
id: "cliRelease",
|
|
125
|
+
ok: false,
|
|
126
|
+
note: true,
|
|
127
|
+
line: `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: npm install -g @catalyst-cloud/catalyst-skills@latest && catalyst-skills login`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// clause 1 — the skill files on disk, read independently of whichever CLI is answering. Both
|
|
131
|
+
// facts can hold at once: a machine that pulled at different hours carries some skills stamped
|
|
132
|
+
// behind AND some old enough to carry no stamp at all — the field report's own mixed install.
|
|
133
|
+
// So they are ADDITIVE, not exclusive: an unstamped file's real version is unknown and may be
|
|
134
|
+
// older than any stamp, which is why the stamped half says "oldest STAMPED" and the unstamped
|
|
135
|
+
// names are always printed rather than hidden behind whichever branch happened to win.
|
|
136
|
+
const found = installedBundleVersion(skillsDir, deps.skillNames);
|
|
137
|
+
const stampBehind = found.version !== null && semverOlder(found.version, pub.latest);
|
|
138
|
+
const unstampedBehind = found.unstamped.length > 0 && !semverOlder(pub.latest, FIRST_STAMPED_VERSION);
|
|
139
|
+
if (stampBehind || unstampedBehind) {
|
|
140
|
+
const facts = [];
|
|
141
|
+
if (stampBehind)
|
|
142
|
+
facts.push(`the oldest stamped skill is ${found.version} (${found.skill})`);
|
|
143
|
+
if (unstampedBehind) {
|
|
144
|
+
const one = found.unstamped.length === 1;
|
|
145
|
+
facts.push(`${found.unstamped.join(", ")} ${one ? "carries" : "carry"} no version and so ${one ? "predates" : "predate"} ${FIRST_STAMPED_VERSION}`);
|
|
146
|
+
}
|
|
147
|
+
checks.push({
|
|
148
|
+
id: "skillsRelease",
|
|
149
|
+
ok: false,
|
|
150
|
+
note: true,
|
|
151
|
+
line: `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
82
156
|
if (cfg) {
|
|
83
157
|
checks.push(cfg.cliPath && existsSync(cfg.cliPath)
|
|
84
158
|
? { id: "cliPath", ok: true, line: `cliPath: ${cfg.cliPath}` }
|
|
@@ -87,7 +161,6 @@ export async function readyReport(ctx, deps) {
|
|
|
87
161
|
// The skills are installed by the customer's own agent (a plugin, or `npx skills add`), so an
|
|
88
162
|
// empty copy directory is the normal case and must not read as NOT READY. A PARTIAL copy is the
|
|
89
163
|
// one broken state this check can see: half a set this package put there and never finished.
|
|
90
|
-
const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
|
|
91
164
|
const present = deps.skillNames.filter((n) => existsSync(join(skillsDir, n, "SKILL.md")));
|
|
92
165
|
const missing = deps.skillNames.filter((n) => !present.includes(n));
|
|
93
166
|
if (present.length === 0) {
|
|
@@ -107,12 +180,30 @@ export async function readyReport(ctx, deps) {
|
|
|
107
180
|
checks.push({ id: "sdk", ok: false, line: `sdk: ${err instanceof Error ? err.message : String(err)}`, fix: "run under Node 22.15 or newer (or bun); every read still works through the API meanwhile", who: "you" });
|
|
108
181
|
}
|
|
109
182
|
const replica = replicaStatus(ctx, cfg);
|
|
110
|
-
checks.push({ id: "replica", ok: replica.verdict === "fresh", note: true, line:
|
|
183
|
+
checks.push({ id: "replica", ok: replica.verdict === "fresh", note: true, line: readyReplicaLine(replica) });
|
|
111
184
|
if (cache) {
|
|
112
185
|
const doc = cache.doc;
|
|
113
186
|
const who = whoCanAnswer(doc);
|
|
114
187
|
for (const team of doc.teams) {
|
|
115
188
|
const label = team.key ?? team.id;
|
|
189
|
+
// The team's dispatch gate, straight off the cached contract: an older cloud omits it and this
|
|
190
|
+
// emits nothing (the `skillsBundle` precedent above). A shut gate means NOTHING in the team can
|
|
191
|
+
// start, so it is a FAIL carrying the cloud's own remedy as the fix — never an informational
|
|
192
|
+
// note. It is emitted before the `unchecked` branch below so a team whose readiness was never
|
|
193
|
+
// checked still reports its gate, which is exactly the team most likely to be unmapped.
|
|
194
|
+
const dg = team.dispatchGate;
|
|
195
|
+
if (dg && typeof dg.status === "string") {
|
|
196
|
+
const slots = (dg.missingSlots ?? []).join(", ");
|
|
197
|
+
checks.push(dg.status === "open"
|
|
198
|
+
? { id: `team:${label}:dispatchGate`, ok: true, line: `team ${label}: dispatch gate open` }
|
|
199
|
+
: {
|
|
200
|
+
id: `team:${label}:dispatchGate`,
|
|
201
|
+
ok: false,
|
|
202
|
+
line: `team ${label}: dispatch gate ${dg.status}${slots ? ` (${slots})` : ""}, blocking`,
|
|
203
|
+
fix: dg.remedy ?? `open settings for team ${label} and map its stages`,
|
|
204
|
+
who,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
116
207
|
if (team.readiness.status === "unchecked") {
|
|
117
208
|
checks.push({ id: `team:${label}`, ok: true, note: true, line: `team ${label}: readiness not checked yet` });
|
|
118
209
|
continue;
|
|
@@ -140,7 +231,7 @@ export async function readyReport(ctx, deps) {
|
|
|
140
231
|
}
|
|
141
232
|
}
|
|
142
233
|
const ready = checks.every((c) => c.ok || c.note);
|
|
143
|
-
return { ready, checks };
|
|
234
|
+
return { ready, checks, replica };
|
|
144
235
|
}
|
|
145
236
|
export async function cmdReady(args, ctx, deps) {
|
|
146
237
|
const report = await readyReport(ctx, deps);
|