@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +13 -9
  3. package/dist/args.js +16 -4
  4. package/dist/ask.js +6 -3
  5. package/dist/cli.js +9 -4
  6. package/dist/config.js +4 -0
  7. package/dist/contract.js +6 -0
  8. package/dist/environment.js +207 -0
  9. package/dist/execution.js +97 -15
  10. package/dist/pagination.js +76 -0
  11. package/dist/published.js +75 -0
  12. package/dist/query.js +41 -18
  13. package/dist/ready.js +110 -19
  14. package/dist/replica.js +295 -35
  15. package/dist/semver.js +18 -0
  16. package/dist/skill-shape.js +10 -0
  17. package/dist/skills.js +52 -1
  18. package/dist/ts-deps-loader.js +46 -1
  19. package/package.json +5 -3
  20. package/skills/catalyst-github/SKILL.md +1 -1
  21. package/skills/catalyst-linear/SKILL.md +1 -1
  22. package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
  23. package/skills/catalyst-onboard/SKILL.md +49 -0
  24. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  25. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  26. package/skills/catalyst-onboard/references/the-one-path.md +143 -0
  27. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  28. package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
  29. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  30. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  31. package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
  32. package/skills/catalyst-setup/SKILL.md +2 -2
  33. package/skills/catalyst-setup/references/what-each-check-means.md +17 -5
  34. package/skills/connect-me/SKILL.md +1 -1
  35. package/skills/how-catalyst-works/SKILL.md +1 -1
  36. package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
  37. package/skills/run-this-project/SKILL.md +1 -1
  38. package/skills/unstick/SKILL.md +1 -1
  39. package/skills/what-needs-me/SKILL.md +1 -1
  40. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  41. package/skills/whats-happening/SKILL.md +1 -1
  42. 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
- const res = await api.getJson("/api/v1/issues", { query: common });
94
- return applyFilters(rowsOf(res.body), f).slice(0, f.limit);
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 res = await api.getJson("/api/v1/pulls", { query: { limit: f.limit, ticket: flagString(args, "ticket") } });
103
- return rowsOf(res.body).slice(0, f.limit);
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 { replicaStatus, statusLine } from "./replica.js";
9
+ import { latestPublishedVersion } from "./published.js";
10
+ import { replicaStatus, writerIsRunning } from "./replica.js";
10
11
  import { loadSdk } from "./sdk.js";
11
- /** True when `a` is a semver-older release than `b`. Prerelease/build metadata is ignored; a segment
12
- * that does not parse counts as 0. A tiny compare on purpose — no dependency for three fields. */
13
- export function semverOlder(a, b) {
14
- const parse = (v) => {
15
- const core = v.split("-")[0].split("+")[0];
16
- const p = core.split(".");
17
- return [Number(p[0]) || 0, Number(p[1]) || 0, Number(p[2]) || 0];
18
- };
19
- const [a0, a1, a2] = parse(a);
20
- const [b0, b1, b2] = parse(b);
21
- if (a0 !== b0)
22
- return a0 < b0;
23
- if (a1 !== b1)
24
- return a1 < b1;
25
- return a2 < b2;
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: statusLine(replica) });
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);