@catalyst-cloud/catalyst-skills 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +19 -12
  3. package/dist/args.js +34 -4
  4. package/dist/ask.js +6 -3
  5. package/dist/cli.js +19 -5
  6. package/dist/config.js +4 -0
  7. package/dist/contract.js +6 -0
  8. package/dist/environment.js +207 -0
  9. package/dist/events.js +106 -0
  10. package/dist/execution.js +146 -18
  11. package/dist/pagination.js +76 -0
  12. package/dist/published.js +75 -0
  13. package/dist/query.js +41 -18
  14. package/dist/ready.js +110 -19
  15. package/dist/release.js +142 -0
  16. package/dist/replica.js +296 -30
  17. package/dist/semver.js +18 -0
  18. package/dist/skill-shape.js +10 -0
  19. package/dist/skills.js +52 -1
  20. package/dist/transport.js +5 -2
  21. package/dist/ts-deps-loader.js +46 -1
  22. package/package.json +7 -4
  23. package/skills/catalyst-github/SKILL.md +1 -1
  24. package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
  25. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  26. package/skills/catalyst-linear/SKILL.md +1 -1
  27. package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
  28. package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
  29. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  30. package/skills/catalyst-onboard/SKILL.md +49 -0
  31. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  32. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  33. package/skills/catalyst-onboard/references/the-one-path.md +143 -0
  34. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  35. package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
  36. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  37. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  38. package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
  39. package/skills/catalyst-setup/SKILL.md +3 -3
  40. package/skills/catalyst-setup/references/what-each-check-means.md +22 -6
  41. package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
  42. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  43. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  44. package/skills/connect-me/SKILL.md +1 -1
  45. package/skills/connect-me/scripts/lib/cli.mjs +3 -2
  46. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  47. package/skills/how-catalyst-works/SKILL.md +1 -1
  48. package/skills/how-catalyst-works/references/stages-and-mapping.md +2 -2
  49. package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
  50. package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
  51. package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
  52. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  53. package/skills/run-this-project/SKILL.md +2 -2
  54. package/skills/run-this-project/references/making-work-ready.md +1 -1
  55. package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
  56. package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
  57. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  58. package/skills/unstick/SKILL.md +41 -0
  59. package/skills/unstick/agents/openai.yaml +6 -0
  60. package/skills/unstick/agents/portability.yaml +5 -0
  61. package/skills/unstick/references/playbook.md +51 -0
  62. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  63. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  64. package/skills/unstick/scripts/unstick.mjs +57 -0
  65. package/skills/what-needs-me/SKILL.md +1 -1
  66. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  67. package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
  68. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  69. package/skills/whats-happening/SKILL.md +2 -2
  70. package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
  71. package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
  72. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  73. package/skills/whats-happening/scripts/snapshot.mjs +4 -0
package/dist/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);
@@ -0,0 +1,142 @@
1
+ // release.ts — `catalyst-skills release`: a person's release of a parked or held ticket, or of every
2
+ // ticket on one team parked under one failure class, through the contract's `ticket-release` and
3
+ // `ticket-release-class` routes. The cloud reads every governor holding the ticket and either releases
4
+ // all of them or releases nothing and names, per governor, the human action that does fix it; this verb
5
+ // prints that answer and exits 1 on a refusal so a script can branch on it.
6
+ //
7
+ // ⛔ NOTHING IS RELEASED WITHOUT A REASON. `--because` (what changed) is required on a real release and
8
+ // is recorded in the tenant's release audit; only `--dry-run` may omit it. `--retry-unchanged` is the
9
+ // explicit statement that something the mirror cannot see changed — the cloud refuses an unchanged
10
+ // cause without it.
11
+ import { flagBool, flagString, positionals } from "./args.js";
12
+ import { requireConfig } from "./config.js";
13
+ import { loadContract } from "./contract.js";
14
+ import { CliError, UsageError } from "./errors.js";
15
+ import { needsNewerCloud } from "./execution.js";
16
+ import { apiClient } from "./transport.js";
17
+ /** One release written as a person reads it: "unparked implement", "cleared the validate hold". */
18
+ export function describeAction(a) {
19
+ switch (a.op) {
20
+ case "unpark":
21
+ return `unparked ${a.phase ?? "a phase"}`;
22
+ case "clear_validate_hold":
23
+ return "cleared the validate hold";
24
+ case "clear_no_change_hold":
25
+ return "cleared the no-change hold";
26
+ case "grant_round_threshold_cycle":
27
+ return "bought one more repair cycle";
28
+ default:
29
+ return `${a.op}${a.phase ? ` at ${a.phase}` : ""}`;
30
+ }
31
+ }
32
+ const refusalLine = (r) => ` ${r.code}: ${r.humanAction}`;
33
+ export function renderTicketRelease(ticket, r) {
34
+ if (r.error === "team_changed") {
35
+ return [`${ticket} moved from team ${r.expectedTeamKey ?? "(none)"} to ${r.teamKey ?? "(none)"} while the release was being checked — run it again`];
36
+ }
37
+ const lines = [];
38
+ const dry = r.dryRun === true;
39
+ if (r.outcome === "nothing-held") {
40
+ lines.push(`${ticket}: nothing holds this ticket — run \`catalyst-skills explain ${ticket}\` for why it is not running`);
41
+ }
42
+ else if (r.outcome === "refused") {
43
+ lines.push(dry ? `${ticket} (dry run): would be refused — nothing would be released:` : `${ticket}: refused — nothing was released:`);
44
+ for (const x of r.refused ?? [])
45
+ lines.push(refusalLine(x));
46
+ const blocked = r.releasable ?? [];
47
+ if (blocked.length > 0)
48
+ lines.push(` (it would otherwise have ${blocked.map(describeAction).join(", ")})`);
49
+ }
50
+ else if (dry) {
51
+ lines.push(`${ticket} (dry run): would release — ${(r.releasable ?? []).map(describeAction).join(", ")}`);
52
+ }
53
+ else {
54
+ lines.push(`${ticket}: released — ${(r.released ?? []).map(describeAction).join(", ")}`);
55
+ }
56
+ for (const w of r.warnings ?? [])
57
+ lines.push(`warning: ${w}`);
58
+ return lines;
59
+ }
60
+ export function renderClassRelease(r) {
61
+ const released = r.released ?? [];
62
+ const refused = r.refused ?? [];
63
+ const nothing = r.nothingHeld ?? [];
64
+ const head = `${r.team ?? "?"} ${r.class ?? "?"}${r.dryRun ? " (dry run)" : ""}: released ${released.length}, refused ${refused.length}, nothing held ${nothing.length}${r.truncated ? " (more remain — run it again)" : ""}`;
65
+ const lines = [head];
66
+ for (const t of released) {
67
+ const actions = r.dryRun ? (t.releasable ?? []) : (t.released ?? []);
68
+ lines.push(` ${r.dryRun ? "would release" : "released"} ${t.ticket ?? "?"}: ${actions.map(describeAction).join(", ")}`);
69
+ }
70
+ for (const t of refused) {
71
+ for (const x of t.refused ?? [])
72
+ lines.push(` refused ${t.ticket ?? "?"} — ${x.code}: ${x.humanAction}`);
73
+ }
74
+ if (nothing.length > 0)
75
+ lines.push(` nothing held: ${nothing.join(", ")}`);
76
+ return lines;
77
+ }
78
+ /** The contract's route by name, or the "your cloud is older" error when this tenant does not serve it. */
79
+ function releaseRoute(doc, name, cfg) {
80
+ const route = doc.routes.find((r) => r.method === "POST" && r.path.split("/").filter(Boolean).at(-1) === name);
81
+ if (!route)
82
+ throw needsNewerCloud(`release (the tenant contract serves no "${name}" route)`, cfg);
83
+ return route.path;
84
+ }
85
+ export async function cmdRelease(args, ctx) {
86
+ const [ticket, ...extra] = positionals(args);
87
+ const failureClass = flagString(args, "class");
88
+ const team = flagString(args, "team");
89
+ const because = flagString(args, "because");
90
+ const dryRun = flagBool(args, "dry-run");
91
+ const retryUnchanged = flagBool(args, "retry-unchanged");
92
+ if (extra.length > 0)
93
+ throw new UsageError("release takes one ticket");
94
+ if (failureClass !== undefined && ticket !== undefined) {
95
+ throw new UsageError("release takes a ticket or --class with --team, not both");
96
+ }
97
+ if (failureClass === undefined && ticket === undefined) {
98
+ throw new UsageError("release needs a ticket, or --class <failure-class> --team <K>");
99
+ }
100
+ if (failureClass !== undefined && team === undefined)
101
+ throw new UsageError("release --class needs --team <K>");
102
+ if (because === undefined && !dryRun) {
103
+ throw new UsageError("release needs --because <what changed> (recorded in the release audit), or --dry-run to preview");
104
+ }
105
+ const limitRaw = flagString(args, "limit");
106
+ const limit = limitRaw === undefined ? undefined : Number(limitRaw);
107
+ if (limit !== undefined && !Number.isInteger(limit))
108
+ throw new UsageError("--limit must be an integer");
109
+ const cfg = requireConfig(ctx);
110
+ const { doc } = await loadContract(ctx, cfg);
111
+ const api = apiClient(cfg, ctx);
112
+ const common = { ...(because !== undefined ? { because } : {}), retryUnchanged, dryRun };
113
+ if (failureClass !== undefined) {
114
+ const path = releaseRoute(doc, "ticket-release-class", cfg);
115
+ const res = await api.postJson(path, {
116
+ team,
117
+ class: failureClass,
118
+ ...common,
119
+ ...(limit !== undefined ? { limit } : {}),
120
+ });
121
+ if (args.json)
122
+ ctx.stdout(JSON.stringify(res.body));
123
+ else
124
+ for (const line of renderClassRelease(res.body))
125
+ ctx.stdout(line);
126
+ // A real class release that refused any ticket exits 1, the same contract as a single refusal.
127
+ return res.body.dryRun !== true && (res.body.refused ?? []).length > 0 ? 1 : 0;
128
+ }
129
+ const id = ticket;
130
+ const path = releaseRoute(doc, "ticket-release", cfg);
131
+ const res = await api.postJson(path, { ticket: id, ...common }, { accept: [404, 409] });
132
+ if (res.status === 404) {
133
+ throw new CliError(`ticket ${id} is not in this tenant's mirror — check the identifier`, "ticket-unknown");
134
+ }
135
+ if (args.json)
136
+ ctx.stdout(JSON.stringify(res.body));
137
+ else
138
+ for (const line of renderTicketRelease(id, res.body))
139
+ ctx.stdout(line);
140
+ const refused = res.status === 409 || (res.body.outcome === "refused" && res.body.dryRun !== true);
141
+ return refused ? 1 : 0;
142
+ }