@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.1

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 (112) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +109 -53
  3. package/bin/catalyst-skills.js +42 -2
  4. package/dist/args.js +242 -0
  5. package/dist/ask.js +137 -0
  6. package/dist/cli.js +167 -279
  7. package/dist/config.js +106 -0
  8. package/dist/contract-types.js +4 -0
  9. package/dist/contract.js +178 -0
  10. package/dist/detach.js +10 -0
  11. package/dist/errors.js +27 -0
  12. package/dist/execution.js +308 -0
  13. package/dist/http.js +175 -0
  14. package/dist/prompt.js +35 -0
  15. package/dist/query.js +225 -0
  16. package/dist/ready.js +123 -0
  17. package/dist/replica.js +318 -0
  18. package/dist/sdk.js +26 -0
  19. package/dist/skill-shape.js +179 -0
  20. package/dist/skills.js +74 -0
  21. package/dist/ts-deps-loader.js +68 -0
  22. package/dist/watch/consumer.js +141 -0
  23. package/dist/watch/cursor-file.js +62 -0
  24. package/dist/watch.js +173 -0
  25. package/dist/write.js +224 -0
  26. package/package.json +12 -7
  27. package/skills/catalyst-github/SKILL.md +35 -0
  28. package/skills/catalyst-github/agents/openai.yaml +6 -0
  29. package/skills/catalyst-github/agents/portability.yaml +4 -0
  30. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  31. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  32. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  33. package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
  34. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  35. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  36. package/skills/catalyst-linear/SKILL.md +43 -0
  37. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  38. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  39. package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
  40. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  41. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  42. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  43. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  44. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  45. package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
  46. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  47. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  48. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  49. package/skills/catalyst-setup/SKILL.md +32 -0
  50. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  51. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  52. package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
  53. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  54. package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
  55. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  56. package/skills/connect-me/SKILL.md +55 -0
  57. package/skills/connect-me/agents/openai.yaml +6 -0
  58. package/skills/connect-me/agents/portability.yaml +5 -0
  59. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  60. package/skills/connect-me/scripts/lib/cli.mjs +184 -0
  61. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  62. package/skills/how-catalyst-works/SKILL.md +42 -0
  63. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  64. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  65. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  66. package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
  67. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  68. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  69. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  70. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  71. package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
  72. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  73. package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
  74. package/skills/run-this-project/SKILL.md +45 -0
  75. package/skills/run-this-project/agents/openai.yaml +6 -0
  76. package/skills/run-this-project/agents/portability.yaml +5 -0
  77. package/skills/run-this-project/assets/stall-policy.json +15 -0
  78. package/skills/run-this-project/references/making-work-ready.md +60 -0
  79. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  80. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  81. package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
  82. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  83. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  84. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  85. package/skills/what-needs-me/SKILL.md +41 -0
  86. package/skills/what-needs-me/agents/openai.yaml +6 -0
  87. package/skills/what-needs-me/agents/portability.yaml +5 -0
  88. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  89. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  90. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  91. package/skills/what-needs-me/scripts/inbox.mjs +47 -0
  92. package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
  93. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  94. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  95. package/skills/whats-happening/SKILL.md +43 -0
  96. package/skills/whats-happening/agents/openai.yaml +6 -0
  97. package/skills/whats-happening/agents/portability.yaml +4 -0
  98. package/skills/whats-happening/assets/status-reply.json +77 -0
  99. package/skills/whats-happening/references/reading-the-board.md +43 -0
  100. package/skills/whats-happening/references/reprioritising.md +37 -0
  101. package/skills/whats-happening/references/routing-work.md +36 -0
  102. package/skills/whats-happening/references/status-reply.md +34 -0
  103. package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
  104. package/skills/whats-happening/scripts/explain.mjs +28 -0
  105. package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
  106. package/skills/whats-happening/scripts/snapshot.mjs +145 -0
  107. package/skills/ask/SKILL.md +0 -37
  108. package/skills/concierge/SKILL.md +0 -41
  109. package/skills/join/SKILL.md +0 -30
  110. package/skills/linearis/SKILL.md +0 -40
  111. package/skills/setup/SKILL.md +0 -27
  112. package/skills/steward/SKILL.md +0 -30
@@ -0,0 +1,178 @@
1
+ // contract.ts — GET /api/v1/agent/contract, cached with its ETag at ~/.config/catalyst-cloud/contract.json.
2
+ // Every tenant fact a verb needs (stage ids, label ids, route paths, the bookkeeping marker, thresholds)
3
+ // is read from here; no verb carries a literal fallback for any of them.
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { contractPathFor, readManifest } from "./config.js";
7
+ import { CliError, MeError } from "./errors.js";
8
+ import { apiClient } from "./http.js";
9
+ export const CONTRACT_ROUTE = "/api/v1/agent/contract";
10
+ export function readContractCache(home) {
11
+ const path = contractPathFor(home);
12
+ if (!existsSync(path))
13
+ return null;
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
16
+ if (typeof parsed.fetchedAt !== "string" || typeof parsed.contractVersion !== "string" || !parsed.doc)
17
+ return null;
18
+ return parsed;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function writeContractCache(home, cache) {
25
+ const path = contractPathFor(home);
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ const tmp = `${path}.tmp`;
28
+ writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
29
+ renameSync(tmp, path);
30
+ return path;
31
+ }
32
+ /** `1.x` → major 1; `1.2.3` → exact; `unpinned` → anything. Returns null when the range is unparseable. */
33
+ export function contractVersionInRange(version, range) {
34
+ if (range === "unpinned")
35
+ return true;
36
+ const major = /^(\d+)\.x$/.exec(range);
37
+ if (major)
38
+ return version.split(".")[0] === major[1];
39
+ if (/^\d+\.\d+\.\d+$/.test(range))
40
+ return version === range;
41
+ return null;
42
+ }
43
+ export function assertContractRange(version, range) {
44
+ const ok = contractVersionInRange(version, range);
45
+ if (ok === null)
46
+ throw new CliError(`tenantContractRange "${range}" in package.json is not a range this CLI understands`, "contract-range");
47
+ if (!ok) {
48
+ throw new CliError(`the tenant serves contract version ${version} but this bundle accepts ${range} — update the bundle (npm update -g @catalyst-cloud/catalyst-skills) or ask your tenant admin which version is live`, "contract-version");
49
+ }
50
+ }
51
+ /**
52
+ * Load the contract per the cloud's own cache policy: a cache younger than `doc.cache.maxAgeSeconds`
53
+ * is used as-is; otherwise a conditional GET revalidates it (304 refreshes `fetchedAt`, 200 replaces
54
+ * the document). A network failure on a cache older than `staleRefusalSeconds` refuses; a 403 (a
55
+ * workstation key) refuses naming the account key; a major version outside the bundle's range refuses
56
+ * naming both versions.
57
+ */
58
+ export async function loadContract(ctx, cfg, opts = {}) {
59
+ const path = contractPathFor(ctx.home);
60
+ const cached = readContractCache(ctx.home);
61
+ const nowMs = ctx.now().getTime();
62
+ const ageSeconds = cached ? Math.max(0, Math.floor((nowMs - Date.parse(cached.fetchedAt)) / 1000)) : Infinity;
63
+ const range = readManifest().tenantContractRange;
64
+ if (cached && !opts.refresh && ageSeconds < cached.doc.cache.maxAgeSeconds) {
65
+ assertContractRange(cached.contractVersion, range);
66
+ return { doc: cached.doc, source: "cache", ageSeconds, path };
67
+ }
68
+ if (opts.offline) {
69
+ if (cached) {
70
+ assertContractRange(cached.contractVersion, range);
71
+ return { doc: cached.doc, source: "cache", ageSeconds, path };
72
+ }
73
+ throw new CliError("no cached contract and network reads are disabled", "contract-missing");
74
+ }
75
+ const api = apiClient(cfg, ctx);
76
+ let res;
77
+ try {
78
+ res = await api.getJson(CONTRACT_ROUTE, { etag: cached?.etag ?? null, accept: [304] });
79
+ }
80
+ catch (err) {
81
+ if (err instanceof CliError && err.status === 403) {
82
+ throw new CliError(`GET ${CONTRACT_ROUTE} refused (403): the contract needs an account key (ctc_acct_…); this key is a workstation key — ask your tenant admin for an account key`, "contract-forbidden", 2, 403);
83
+ }
84
+ if (err instanceof MeError && err.kind === "network" && cached) {
85
+ if (ageSeconds >= cached.doc.cache.staleRefusalSeconds) {
86
+ throw new CliError(`the cached contract is ${ageSeconds}s old (refusal after ${cached.doc.cache.staleRefusalSeconds}s) and the refresh failed: ${err.message}`, "contract-stale");
87
+ }
88
+ assertContractRange(cached.contractVersion, range);
89
+ return { doc: cached.doc, source: "cache", ageSeconds, path };
90
+ }
91
+ throw err;
92
+ }
93
+ if (res.status === 304) {
94
+ if (!cached)
95
+ throw new CliError(`GET ${CONTRACT_ROUTE} answered 304 with no cache to revalidate`, "contract-shape");
96
+ assertContractRange(cached.contractVersion, range);
97
+ writeContractCache(ctx.home, { ...cached, fetchedAt: ctx.now().toISOString() });
98
+ return { doc: cached.doc, source: "revalidated", ageSeconds: 0, path };
99
+ }
100
+ const doc = res.body;
101
+ if (!doc || typeof doc !== "object" || typeof doc.contractVersion !== "string" || !Array.isArray(doc.teams) || !Array.isArray(doc.routes)) {
102
+ throw new CliError(`GET ${CONTRACT_ROUTE} returned an unexpected shape`, "contract-shape");
103
+ }
104
+ const version = res.headers.get("x-catalyst-contract-version") ?? doc.contractVersion;
105
+ assertContractRange(version, range);
106
+ writeContractCache(ctx.home, {
107
+ etag: res.headers.get("etag"),
108
+ fetchedAt: ctx.now().toISOString(),
109
+ contractVersion: version,
110
+ doc,
111
+ });
112
+ return { doc, source: "network", ageSeconds: 0, path };
113
+ }
114
+ // ── helpers every verb uses ──────────────────────────────────────────────────────────────────────
115
+ export function teamByKey(doc, key) {
116
+ const wanted = key.toUpperCase();
117
+ const team = doc.teams.find((t) => (t.key ?? "").toUpperCase() === wanted);
118
+ if (!team) {
119
+ const known = doc.teams.map((t) => t.key ?? t.id).join(", ") || "none";
120
+ throw new CliError(`team "${key}" is not on this tenant's contract (teams: ${known})`, "team-unknown");
121
+ }
122
+ return team;
123
+ }
124
+ export function teamById(doc, id) {
125
+ return doc.teams.find((t) => t.id === id) ?? null;
126
+ }
127
+ /** The team a ticket identifier belongs to, by its `KEY-` prefix. */
128
+ export function teamForTicket(doc, ticket) {
129
+ const dash = ticket.indexOf("-");
130
+ if (dash <= 0)
131
+ throw new CliError(`"${ticket}" is not a ticket identifier (expected KEY-123)`, "ticket-shape");
132
+ return teamByKey(doc, ticket.slice(0, dash));
133
+ }
134
+ export function stageIdForSlot(team, slot) {
135
+ const stage = team.stages[slot];
136
+ if (!stage) {
137
+ const mapped = Object.keys(team.stages).join(", ") || "none";
138
+ throw new CliError(`slot "${slot}" is not mapped for team ${team.key ?? team.id} (mapped: ${mapped})`, "slot-unmapped");
139
+ }
140
+ if (!stage.stateStillExists) {
141
+ throw new CliError(`slot "${slot}" on team ${team.key ?? team.id} maps to state ${stage.stateId}, which no longer exists in Linear — fix the mapping in settings first`, "slot-stale");
142
+ }
143
+ return stage.stateId;
144
+ }
145
+ /** Resolve a label by name through the contract's ask/hold/release lists; an unknown name is returned
146
+ * as-is when it looks like an id (the contract only lists Catalyst's own labels). */
147
+ export function labelId(team, nameOrId) {
148
+ const all = [...team.labels.ask, ...team.labels.hold, ...team.labels.release];
149
+ const hit = all.find((l) => l.name === nameOrId);
150
+ if (hit) {
151
+ if (!hit.preferredId) {
152
+ throw new CliError(`label "${nameOrId}" is absent from this workspace (contract scope: ${hit.scope}) — create it in Linear first`, "label-absent");
153
+ }
154
+ return hit.preferredId;
155
+ }
156
+ return nameOrId;
157
+ }
158
+ /** The route whose last path segment is `name` (e.g. `issue-comment`). The path itself is never assumed. */
159
+ export function routePath(doc, name) {
160
+ const route = doc.routes.find((r) => r.path.split("/").filter(Boolean).at(-1) === name);
161
+ if (!route) {
162
+ throw new CliError(`the tenant contract serves no "${name}" route (routes: ${doc.routes.map((r) => r.path).join(", ")})`, "route-unknown");
163
+ }
164
+ return route.path;
165
+ }
166
+ export function bookkeepingPrefix(doc) {
167
+ return doc.vocabulary.bookkeeping.marker;
168
+ }
169
+ /** Walk a dotted path (`teams.0.stages`) into the document. */
170
+ export function pickPath(doc, path) {
171
+ let cur = doc;
172
+ for (const seg of path.split(".").filter(Boolean)) {
173
+ if (cur === null || typeof cur !== "object")
174
+ return undefined;
175
+ cur = cur[seg];
176
+ }
177
+ return cur;
178
+ }
package/dist/detach.js ADDED
@@ -0,0 +1,10 @@
1
+ // detach.ts — re-spawn this CLI detached (replica start --detach). Isolated so a test can stub it.
2
+ import { spawn } from "node:child_process";
3
+ /** Spawn `process.execPath <argv>` detached from this process, stdio ignored, and let it go. */
4
+ export function detachSelf(argv, env = process.env) {
5
+ const child = spawn(process.execPath, argv, { detached: true, stdio: "ignore", env });
6
+ child.unref();
7
+ if (typeof child.pid !== "number")
8
+ throw new Error("detached spawn returned no pid");
9
+ return { pid: child.pid };
10
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,27 @@
1
+ // errors.ts — the three error classes the exit contract is built on: usage → 1, MeError/CliError → 2.
2
+ export class UsageError extends Error {
3
+ }
4
+ export class CliError extends Error {
5
+ code;
6
+ exitCode;
7
+ status;
8
+ constructor(message, code,
9
+ /** Exit code the dispatcher returns for this error. Defaults to 2 (the CliError contract). */
10
+ exitCode = 2,
11
+ /** The HTTP status that produced it, when one did. */
12
+ status) {
13
+ super(message);
14
+ this.code = code;
15
+ this.exitCode = exitCode;
16
+ this.status = status;
17
+ }
18
+ }
19
+ export class MeError extends Error {
20
+ kind;
21
+ status;
22
+ constructor(message, kind, status) {
23
+ super(message);
24
+ this.kind = kind;
25
+ this.status = status;
26
+ }
27
+ }
@@ -0,0 +1,308 @@
1
+ // execution.ts — what Catalyst is doing: `explain <ticket>`, `running`, `queue`, `accounts`.
2
+ // `explain` turns one work-eligibility row into a paragraph a human can act on, translating every
3
+ // exclusion reason and unknown the cloud names; an unlisted reason prints its raw string, never nothing.
4
+ import { flagBool, flagString, positionals } from "./args.js";
5
+ import { normalizeBaseUrl, requireConfig } from "./config.js";
6
+ import { loadContract } from "./contract.js";
7
+ import { CliError, UsageError } from "./errors.js";
8
+ import { apiClient } from "./http.js";
9
+ /** Every exclusion reason the eligibility evaluator names, in plain English. */
10
+ export const EXCLUSION_REASONS = {
11
+ ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
12
+ pipeline_complete: "every relay phase has already completed",
13
+ not_at_dispatch_stage: "the card is not in the team's dispatch column, so nothing is offered until it is moved there",
14
+ not_at_pr_stage: "merge is next but the card is not in the PR column",
15
+ blocked: "a live blocking relation holds it — the blocker has to close first",
16
+ cooling_down: "the offered phase is parked; a callback or an operator releases it, not a clock",
17
+ lease_held: "a live container already holds the lease for the offered phase",
18
+ intake_lease_held: "a later phase is offered while an intake container still holds this ticket",
19
+ ask_ticket: "it carries an ask label, and a question is never work",
20
+ ask_shape_suspected: "its own text reads as a decision request; a human releases it with the not-an-ask label",
21
+ externally_claimed: "a worker outside the cloud has claimed it",
22
+ environment_check_required: "the repository's environment check has not been run",
23
+ environment_check_running: "the repository's environment check is in flight",
24
+ environment_check_failed: "the repository's environment check failed",
25
+ environment_check_expired: "the repository's environment check verdict aged out",
26
+ environment_check_hash_mismatch: "the environment changed since its recorded verdict",
27
+ scope_overlap: "its declared file scope intersects a ticket already in flight",
28
+ waiting_on: "a merge-gate failure with no remediable cause holds the card at PR",
29
+ branch_missing: "the ticket branch has never been seen, so a branch-dependent phase has nothing to clone",
30
+ branch_gone: "the ticket branch was deleted after it existed",
31
+ pr_merged: "its PR has merged and no other PR is open — nothing is left to clone or re-merge",
32
+ no_change_hold: "a remediate round changed nothing; a human comment or a new push releases it",
33
+ validate_class_spent: "this validate failure already spent its one repair round in this episode",
34
+ stale_failure_episode: "the ladder advanced after the recorded failure, so the round would repair a phase already passed",
35
+ runner_image_breaker: "fleet-wide: the live runner image is failing every phase at startup; dispatch resumes when the pin moves",
36
+ no_branch_to_remediate: "a remediate round is queued on a ticket with no recorded branch",
37
+ retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
38
+ routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
39
+ repo_paused: "an operator paused the repository",
40
+ remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired",
41
+ };
42
+ /** Every fail-closed unknown the evaluator names. */
43
+ export const UNKNOWN_REASONS = {
44
+ ordering_never_published: "the dispatch order has never been published for this team",
45
+ ordering_stale: "the dispatch order is stale",
46
+ workflow_mapping_unknown: "the team's workflow mapping could not be read",
47
+ ticket_unknown: "the ticket is not in the mirror",
48
+ dependency_snapshot_unknown: "the dependency snapshot has never completed",
49
+ blocker_unknown: "a blocking relation could not be resolved",
50
+ label_snapshot_unknown: "the labels poll has never completed, so ask labels cannot be read",
51
+ prior_artifact_unknown: "a prior phase artifact could not be read",
52
+ scope_unknown: "the ticket declared no scope, or the declaration was truncated",
53
+ scope_occupancy_unknown: "an in-flight ticket's state could not be resolved",
54
+ branch_snapshot_unknown: "the pull-request poll has never completed, so branch evidence cannot be read",
55
+ };
56
+ export const ADVISORIES = {
57
+ human_addressed_unlabeled_ask_suspect: "assigned to a human with no delegate: this may be an unlabelled ask",
58
+ };
59
+ export function describeReason(reason) {
60
+ if (!reason)
61
+ return "no reason was given";
62
+ return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
63
+ }
64
+ export function renderExplain(ticket, row, team) {
65
+ if (!row)
66
+ return `${ticket}: not in the ${team} eligibility explainer — the ticket is unknown to the mirror, terminal, or on another team.`;
67
+ const parts = [];
68
+ const pos = typeof row.position === "number" ? `position ${row.position}` : "no queue position";
69
+ const status = row.status ?? "unknown";
70
+ if (status === "offered" || status === "eligible") {
71
+ parts.push(`${ticket} is ${status} (${pos})${row.phase ? ` for phase ${row.phase}` : ""}.`);
72
+ }
73
+ else if (status === "unknown") {
74
+ parts.push(`${ticket} cannot be judged (${pos}): ${describeReason(row.unknown ?? row.reason)}.`);
75
+ }
76
+ else {
77
+ parts.push(`${ticket} is ${status} (${pos}): ${describeReason(row.reason)}.`);
78
+ }
79
+ if (row.detail)
80
+ parts.push(`Detail: ${row.detail}.`);
81
+ if (row.marker)
82
+ parts.push(`Marker: ${row.marker}.`);
83
+ if (row.nextPhase)
84
+ parts.push(`Next phase would be ${row.nextPhase}.`);
85
+ if (row.release)
86
+ parts.push(`Release: ${row.release}.`);
87
+ if (row.failure && typeof row.failure === "object") {
88
+ const f = row.failure;
89
+ const bits = ["phase", "class", "attempts", "consecutive", "lastFailedAt", "message", "summary"]
90
+ .filter((k) => f[k] !== undefined && f[k] !== null)
91
+ .map((k) => `${k}=${typeof f[k] === "string" ? f[k] : JSON.stringify(f[k])}`);
92
+ parts.push(`Last failure: ${bits.length ? bits.join(", ") : JSON.stringify(f)}.`);
93
+ }
94
+ if (row.advisories && row.advisories.length > 0) {
95
+ parts.push(`Advisories: ${row.advisories.map((a) => ADVISORIES[a] ?? a).join("; ")}.`);
96
+ }
97
+ return parts.join(" ");
98
+ }
99
+ export async function cmdExplain(args, ctx) {
100
+ const [ticket] = positionals(args);
101
+ if (!ticket)
102
+ throw new UsageError("explain needs a ticket: explain <ticket>");
103
+ const cfg = requireConfig(ctx);
104
+ // `explain --history` is the same read as `history <ticket>`; both go to CTC-1954's route.
105
+ if (flagBool(args, "history"))
106
+ return await cmdHistory(args, ctx, ticket);
107
+ const { doc } = await loadContract(ctx, cfg);
108
+ const dash = ticket.indexOf("-");
109
+ if (dash <= 0)
110
+ throw new UsageError(`"${ticket}" is not a ticket identifier (expected KEY-123)`);
111
+ const team = ticket.slice(0, dash).toUpperCase();
112
+ const api = apiClient(cfg, ctx);
113
+ const res = await api.getJson("/api/v1/work-eligibility", {
114
+ query: { team, capabilities: doc.ladder.phases.join(",") },
115
+ });
116
+ const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
117
+ const row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
118
+ if (args.json) {
119
+ ctx.stdout(JSON.stringify({ ticket, row, explanation: renderExplain(ticket, row, team) }));
120
+ }
121
+ else {
122
+ ctx.stdout(renderExplain(ticket, row, team));
123
+ }
124
+ return 0;
125
+ }
126
+ /**
127
+ * `running` — the fleet-wide "what is Catalyst doing right now?".
128
+ *
129
+ * ⛔ `/api/v1/lease/attributions` IS NOT A FLEET-WIDE ROUTE. It answers "who wrote this transition,
130
+ * holding which lease?" for ONE (ticket, phase) pair and 400s `invalid_field` without both — 0.2.0
131
+ * called it bare on every `running`, so the headline verb (and `whats-running.mjs`, and
132
+ * `snapshot.mjs`) failed for every tenant. The tenant-wide question is answered by
133
+ * `/fleet-activity/current` (what is executing) and `/agent-roster/current` (who is coordinating),
134
+ * neither of which takes coordinates. Lease attributions stay available, behind the coordinates the
135
+ * route requires.
136
+ */
137
+ export async function cmdRunning(args, ctx) {
138
+ const cfg = requireConfig(ctx);
139
+ const ticket = flagString(args, "ticket");
140
+ const phase = flagString(args, "phase");
141
+ if ((ticket === undefined) !== (phase === undefined)) {
142
+ throw new UsageError("running: --ticket and --phase go together — lease attributions are recorded per (ticket, phase), and the route refuses either one alone");
143
+ }
144
+ const api = apiClient(cfg, ctx);
145
+ const [activity, roster] = await Promise.all([
146
+ api.getJson("/api/v1/fleet-activity/current"),
147
+ api.getJson("/api/v1/agent-roster/current"),
148
+ ]);
149
+ const out = { fleetActivity: activity.body, agentRoster: roster.body };
150
+ if (ticket !== undefined && phase !== undefined) {
151
+ const leases = await api.getJson("/api/v1/lease/attributions", { query: { ticket, phase } });
152
+ out.leaseAttributions = leases.body;
153
+ }
154
+ if (args.json) {
155
+ ctx.stdout(JSON.stringify(out));
156
+ return 0;
157
+ }
158
+ ctx.stdout(`fleet activity: ${JSON.stringify(out.fleetActivity)}`);
159
+ ctx.stdout(`agent roster: ${JSON.stringify(out.agentRoster)}`);
160
+ if (out.leaseAttributions !== undefined) {
161
+ ctx.stdout(`lease attributions (${ticket} ${phase}): ${JSON.stringify(out.leaseAttributions)}`);
162
+ }
163
+ return 0;
164
+ }
165
+ /**
166
+ * `queue` — the dispatch order. The route is per-team and REQUIRES `?team=` ("bad team", 400), but
167
+ * a customer asking "what is next?" rarely means one team, so an omitted `--team` reads every team
168
+ * the tenant contract names rather than sending a call the route will refuse.
169
+ */
170
+ export async function cmdQueue(args, ctx) {
171
+ const cfg = requireConfig(ctx);
172
+ const api = apiClient(cfg, ctx);
173
+ const named = flagString(args, "team");
174
+ let teams;
175
+ if (named !== undefined) {
176
+ teams = [named];
177
+ }
178
+ else {
179
+ const { doc } = await loadContract(ctx, cfg);
180
+ teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
181
+ if (teams.length === 0) {
182
+ throw new UsageError("queue needs --team: the tenant contract names no team to read a queue for");
183
+ }
184
+ }
185
+ const bodies = await Promise.all(teams.map(async (team) => [team, (await api.getJson("/api/v1/dispatch-queue/current", { query: { team } })).body]));
186
+ // One team asked for → its envelope, unwrapped, exactly as before. Several → keyed by team, so a
187
+ // caller can tell whose queue a row belongs to.
188
+ const out = bodies.length === 1 && named !== undefined ? bodies[0][1] : Object.fromEntries(bodies);
189
+ ctx.stdout(args.json ? JSON.stringify(out) : JSON.stringify(out, null, 2));
190
+ return 0;
191
+ }
192
+ /** The five status words `/api/v1/coding-accounts` reports, in the words a customer reads. */
193
+ export const ACCOUNT_STATUS = {
194
+ "expired-or-revoked": "expired or revoked — re-enrol it before it can take work",
195
+ walled: "walled — the provider's usage limit is spent for now",
196
+ active: "active — observed working",
197
+ attested: "attested — healthy at last check, no work observed since",
198
+ unobserved: "unobserved — enrolled, but nothing has been seen from it yet",
199
+ };
200
+ /** One slot as a line a human reads: who it is, what state it is in, and what it is spending on. */
201
+ export function renderAccount(a) {
202
+ const name = a.label ? `${a.accountSlot ?? "?"} (${a.label})` : (a.accountSlot ?? "?");
203
+ const harness = a.harness ? `/${a.harness}` : "";
204
+ const status = a.status ? (ACCOUNT_STATUS[a.status] ?? a.status) : "status unknown";
205
+ const bits = [`${name} ${a.provider ?? "?"}${harness} ${status}`];
206
+ if (typeof a.bindingUsedPercent === "number") {
207
+ const resets = typeof a.bindingResetsAtMs === "number" ? `, resets ${new Date(a.bindingResetsAtMs).toISOString()}` : "";
208
+ bits.push(`usage ${a.bindingUsedPercent}% of the ${a.bindingWindow ?? "binding"} window${resets}`);
209
+ }
210
+ if (a.quarantined)
211
+ bits.push(`quarantined${a.quarantineReason ? `: ${a.quarantineReason}` : ""}`);
212
+ const holds = a.liveHolds ?? [];
213
+ if (holds.length > 0)
214
+ bits.push(`holding ${holds.map((h) => `${h.ticket}/${h.phase}`).join(", ")}`);
215
+ else if (a.liveHoldsCount)
216
+ bits.push(`${a.liveHoldsCount} live hold(s)`);
217
+ return bits.join(" · ");
218
+ }
219
+ /**
220
+ * ⛔ A ROUTE THIS TENANT'S CLOUD DOES NOT SERVE IS SAID OUT LOUD, never rendered as an empty success.
221
+ * `GET /api/v1/coding-accounts` (CTC-1953) and `GET /api/v1/issues/:id/execution` (CTC-1954) ship
222
+ * ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
223
+ * which is a different fact from "you have no coding accounts" and must never print as the latter.
224
+ */
225
+ function needsNewerCloud(what, cfg) {
226
+ return new CliError(`${what} needs a newer Catalyst Cloud than ${normalizeBaseUrl(cfg.baseUrl)} is running — the route answered 404. Nothing is wrong with your tenant; ask your operator when the mirror last deployed.`, "route-not-deployed", 3, 404);
227
+ }
228
+ export async function cmdAccounts(args, ctx) {
229
+ const cfg = requireConfig(ctx);
230
+ const api = apiClient(cfg, ctx);
231
+ const res = await api.getJson("/api/v1/coding-accounts", {
232
+ accept: [404],
233
+ });
234
+ if (res.status === 404)
235
+ throw needsNewerCloud("coding-account status", cfg);
236
+ const accounts = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
237
+ if (args.json) {
238
+ ctx.stdout(JSON.stringify(res.body));
239
+ return 0;
240
+ }
241
+ if (accounts.length === 0) {
242
+ ctx.stdout(`No coding accounts are enrolled on this tenant — enrol one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
243
+ return 0;
244
+ }
245
+ for (const a of accounts)
246
+ ctx.stdout(renderAccount(a));
247
+ return 0;
248
+ }
249
+ /**
250
+ * `history <ticket>` (and `explain --history`) — the ticket's execution history: per-phase attempts
251
+ * and outcomes, remediate rounds against the cap, the park sentinel and what releases it, the live
252
+ * lease, the last advance. Reads `GET /api/v1/issues/:identifier/execution` (CTC-1954).
253
+ */
254
+ export async function cmdHistory(args, ctx, ticketArg) {
255
+ const ticket = ticketArg ?? positionals(args)[0];
256
+ if (!ticket)
257
+ throw new UsageError("history needs a ticket: history <ticket>");
258
+ const cfg = requireConfig(ctx);
259
+ const api = apiClient(cfg, ctx);
260
+ const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}/execution`, {
261
+ accept: [404],
262
+ });
263
+ if (res.status === 404)
264
+ throw needsNewerCloud(`execution history for ${ticket}`, cfg);
265
+ if (args.json) {
266
+ ctx.stdout(JSON.stringify(res.body));
267
+ return 0;
268
+ }
269
+ for (const line of renderHistory(ticket, res.body))
270
+ ctx.stdout(line);
271
+ return 0;
272
+ }
273
+ /** ⛔ `null` is UNREADABLE, never "nothing happened" — the report's own contract, kept in the prose. */
274
+ export function renderHistory(ticket, doc) {
275
+ const lines = [];
276
+ if (doc.hasLadderHistory === false) {
277
+ lines.push(`${ticket}: no ladder history recorded${doc.note ? ` — ${doc.note}` : ""}.`);
278
+ }
279
+ const phases = doc.phases;
280
+ if (phases === null || phases === undefined) {
281
+ lines.push(`${ticket}: the per-phase table could not be read (unreadable, not empty).`);
282
+ }
283
+ else {
284
+ lines.push(`${ticket} phases (${doc.attemptHistory ?? "latest-per-phase"}):`);
285
+ for (const p of phases) {
286
+ const fail = p.lastFailureClass ? `, last failure ${p.lastFailureClass}${p.consecutiveFailures ? ` ×${p.consecutiveFailures}` : ""}` : "";
287
+ lines.push(` ${p.phase}: ${p.status ?? "?"} (attempt ${p.attempt ?? "?"})${fail}`);
288
+ }
289
+ }
290
+ if (doc.failure) {
291
+ const f = doc.failure;
292
+ lines.push(`Last failure: ${f.phase} — ${f.failureMode}${f.failureDetail ? ` (${f.failureDetail})` : ""}${f.summary ? `: ${f.summary}` : ""}`);
293
+ }
294
+ if (doc.remediate && typeof doc.remediate.roundsDispatched === "number") {
295
+ lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
296
+ }
297
+ if (doc.park) {
298
+ lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "needs an operator"} — ${doc.park.releasedBy}`);
299
+ }
300
+ for (const l of doc.lease ?? [])
301
+ lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
302
+ if (doc.lastAdvance) {
303
+ lines.push(`Last advance: ${doc.lastAdvance.phase} → ${doc.lastAdvance.toSlot ?? "?"} (${doc.lastAdvance.landed ? "landed" : "not landed"})`);
304
+ }
305
+ for (const u of doc.unreadable ?? [])
306
+ lines.push(`Unreadable: ${u.table} (${u.error}) — absent from this report, not absent from the ticket.`);
307
+ return lines;
308
+ }