@catalyst-cloud/catalyst-skills 0.5.0 → 0.8.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 (127) hide show
  1. package/README.md +11 -178
  2. package/bin/catalyst-skills.js +3 -49
  3. package/bin/catalyst.js +3 -0
  4. package/package.json +8 -29
  5. package/CHANGELOG.md +0 -41
  6. package/LICENSE +0 -21
  7. package/dist/args.js +0 -261
  8. package/dist/ask.js +0 -158
  9. package/dist/browser.js +0 -20
  10. package/dist/cli.js +0 -356
  11. package/dist/config.js +0 -148
  12. package/dist/contract-types.js +0 -4
  13. package/dist/contract.js +0 -178
  14. package/dist/detach.js +0 -10
  15. package/dist/errors.js +0 -27
  16. package/dist/events.js +0 -106
  17. package/dist/execution.js +0 -369
  18. package/dist/oauth.js +0 -300
  19. package/dist/prompt.js +0 -35
  20. package/dist/query.js +0 -225
  21. package/dist/ready.js +0 -163
  22. package/dist/release.js +0 -142
  23. package/dist/replica.js +0 -325
  24. package/dist/sdk.js +0 -26
  25. package/dist/skill-shape.js +0 -179
  26. package/dist/skills.js +0 -78
  27. package/dist/transport.js +0 -205
  28. package/dist/ts-deps-loader.js +0 -68
  29. package/dist/watch/consumer.js +0 -141
  30. package/dist/watch/cursor-file.js +0 -62
  31. package/dist/watch.js +0 -175
  32. package/dist/write.js +0 -224
  33. package/skills/catalyst-github/SKILL.md +0 -35
  34. package/skills/catalyst-github/agents/openai.yaml +0 -6
  35. package/skills/catalyst-github/agents/portability.yaml +0 -4
  36. package/skills/catalyst-github/references/is-it-mergeable.md +0 -57
  37. package/skills/catalyst-github/references/what-a-pr-accumulates.md +0 -61
  38. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +0 -124
  39. package/skills/catalyst-github/scripts/lib/cli.mjs +0 -103
  40. package/skills/catalyst-github/scripts/lib/credential.mjs +0 -29
  41. package/skills/catalyst-github/scripts/lib/pull.mjs +0 -82
  42. package/skills/catalyst-github/scripts/read-pr.mjs +0 -97
  43. package/skills/catalyst-linear/SKILL.md +0 -43
  44. package/skills/catalyst-linear/agents/openai.yaml +0 -6
  45. package/skills/catalyst-linear/agents/portability.yaml +0 -5
  46. package/skills/catalyst-linear/references/reading-a-ticket.md +0 -50
  47. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +0 -53
  48. package/skills/catalyst-linear/references/writing-to-linear.md +0 -43
  49. package/skills/catalyst-linear/scripts/comment.mjs +0 -59
  50. package/skills/catalyst-linear/scripts/create-ticket.mjs +0 -44
  51. package/skills/catalyst-linear/scripts/label.mjs +0 -48
  52. package/skills/catalyst-linear/scripts/lib/cli.mjs +0 -164
  53. package/skills/catalyst-linear/scripts/lib/credential.mjs +0 -29
  54. package/skills/catalyst-linear/scripts/move.mjs +0 -41
  55. package/skills/catalyst-linear/scripts/read-ticket.mjs +0 -93
  56. package/skills/catalyst-linear/scripts/search.mjs +0 -49
  57. package/skills/catalyst-setup/SKILL.md +0 -32
  58. package/skills/catalyst-setup/agents/openai.yaml +0 -6
  59. package/skills/catalyst-setup/agents/portability.yaml +0 -4
  60. package/skills/catalyst-setup/references/what-each-check-means.md +0 -66
  61. package/skills/catalyst-setup/scripts/check.mjs +0 -75
  62. package/skills/catalyst-setup/scripts/lib/cli.mjs +0 -103
  63. package/skills/catalyst-setup/scripts/lib/credential.mjs +0 -29
  64. package/skills/catalyst-setup/scripts/replica-status.mjs +0 -46
  65. package/skills/connect-me/SKILL.md +0 -63
  66. package/skills/connect-me/agents/openai.yaml +0 -6
  67. package/skills/connect-me/agents/portability.yaml +0 -5
  68. package/skills/connect-me/references/keeping-the-replica-running.md +0 -88
  69. package/skills/connect-me/scripts/lib/cli.mjs +0 -185
  70. package/skills/connect-me/scripts/lib/credential.mjs +0 -29
  71. package/skills/connect-me/scripts/verify-connection.mjs +0 -68
  72. package/skills/how-catalyst-works/SKILL.md +0 -42
  73. package/skills/how-catalyst-works/agents/openai.yaml +0 -6
  74. package/skills/how-catalyst-works/agents/portability.yaml +0 -4
  75. package/skills/how-catalyst-works/references/coding-accounts.md +0 -51
  76. package/skills/how-catalyst-works/references/stages-and-mapping.md +0 -54
  77. package/skills/how-catalyst-works/references/the-ladder.md +0 -41
  78. package/skills/how-catalyst-works/references/what-runs-next.md +0 -77
  79. package/skills/how-catalyst-works/references/when-a-phase-fails.md +0 -57
  80. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +0 -41
  81. package/skills/how-catalyst-works/scripts/lib/cli.mjs +0 -164
  82. package/skills/how-catalyst-works/scripts/lib/credential.mjs +0 -29
  83. package/skills/how-catalyst-works/scripts/show-my-map.mjs +0 -94
  84. package/skills/how-catalyst-works/scripts/whats-running.mjs +0 -65
  85. package/skills/run-this-project/SKILL.md +0 -45
  86. package/skills/run-this-project/agents/openai.yaml +0 -6
  87. package/skills/run-this-project/agents/portability.yaml +0 -5
  88. package/skills/run-this-project/assets/stall-policy.json +0 -15
  89. package/skills/run-this-project/references/making-work-ready.md +0 -60
  90. package/skills/run-this-project/references/reacting-to-events.md +0 -76
  91. package/skills/run-this-project/references/stalls-and-escalation.md +0 -63
  92. package/skills/run-this-project/scripts/lib/cli.mjs +0 -185
  93. package/skills/run-this-project/scripts/lib/credential.mjs +0 -29
  94. package/skills/run-this-project/scripts/make-ready.mjs +0 -64
  95. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  96. package/skills/run-this-project/scripts/watch-scope.mjs +0 -61
  97. package/skills/unstick/SKILL.md +0 -41
  98. package/skills/unstick/agents/openai.yaml +0 -6
  99. package/skills/unstick/agents/portability.yaml +0 -5
  100. package/skills/unstick/references/playbook.md +0 -51
  101. package/skills/unstick/scripts/lib/cli.mjs +0 -135
  102. package/skills/unstick/scripts/lib/credential.mjs +0 -29
  103. package/skills/unstick/scripts/unstick.mjs +0 -57
  104. package/skills/what-needs-me/SKILL.md +0 -41
  105. package/skills/what-needs-me/agents/openai.yaml +0 -6
  106. package/skills/what-needs-me/agents/portability.yaml +0 -5
  107. package/skills/what-needs-me/references/raising-a-decision.md +0 -41
  108. package/skills/what-needs-me/references/reading-the-inbox.md +0 -38
  109. package/skills/what-needs-me/references/settling-an-answer.md +0 -37
  110. package/skills/what-needs-me/scripts/inbox.mjs +0 -56
  111. package/skills/what-needs-me/scripts/lib/cli.mjs +0 -135
  112. package/skills/what-needs-me/scripts/lib/credential.mjs +0 -29
  113. package/skills/what-needs-me/scripts/raise.mjs +0 -53
  114. package/skills/what-needs-me/scripts/settle.mjs +0 -73
  115. package/skills/whats-happening/SKILL.md +0 -43
  116. package/skills/whats-happening/agents/openai.yaml +0 -6
  117. package/skills/whats-happening/agents/portability.yaml +0 -4
  118. package/skills/whats-happening/assets/status-reply.json +0 -77
  119. package/skills/whats-happening/references/reading-the-board.md +0 -43
  120. package/skills/whats-happening/references/reprioritising.md +0 -37
  121. package/skills/whats-happening/references/routing-work.md +0 -36
  122. package/skills/whats-happening/references/status-reply.md +0 -34
  123. package/skills/whats-happening/references/why-is-it-stuck.md +0 -62
  124. package/skills/whats-happening/scripts/explain.mjs +0 -28
  125. package/skills/whats-happening/scripts/lib/cli.mjs +0 -135
  126. package/skills/whats-happening/scripts/lib/credential.mjs +0 -29
  127. package/skills/whats-happening/scripts/snapshot.mjs +0 -145
package/dist/write.js DELETED
@@ -1,224 +0,0 @@
1
- // write.ts — writes to Linear as the app actor through the agent proxy. Every route path comes from
2
- // the contract's route table (never a literal); every stage id from `teams[].stages` by slot; every
3
- // label id from `teams[].labels`; the bookkeeping marker from `vocabulary`. `delegate` is never offered.
4
- import { readFileSync } from "node:fs";
5
- import { flagBool, flagInt, flagList, flagString, positionals } from "./args.js";
6
- import { requireConfig } from "./config.js";
7
- import { bookkeepingPrefix, labelId, loadContract, routePath, stageIdForSlot, teamByKey, teamForTicket } from "./contract.js";
8
- import { CliError, UsageError } from "./errors.js";
9
- import { apiClient } from "./transport.js";
10
- /** A ticket identifier → its Linear id (and team/project) through GET /api/v1/issues/:identifier. */
11
- export async function resolveIssue(api, ticket) {
12
- const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}`, { accept: [404] });
13
- if (res.status === 404 || !res.body || typeof res.body.id !== "string") {
14
- throw new CliError(`ticket ${ticket} is not in the mirror — check the identifier`, "ticket-unknown");
15
- }
16
- return {
17
- id: res.body.id,
18
- identifier: typeof res.body.identifier === "string" ? res.body.identifier : ticket,
19
- teamId: typeof res.body.team_id === "string" ? res.body.team_id : null,
20
- projectId: typeof res.body.project_id === "string" ? res.body.project_id : null,
21
- };
22
- }
23
- /** GET /api/v1/workflow-stages, read tolerantly: `{teams:[{teamId|id|key, states|stages:[...]}]}`,
24
- * `{stages:[...]}` or a bare array of states each carrying `team_id|teamId`. */
25
- export async function fetchWorkflowStates(api) {
26
- const res = await api.getJson("/api/v1/workflow-stages");
27
- const out = [];
28
- const push = (s, teamId) => {
29
- if (typeof s.id !== "string")
30
- return;
31
- out.push({
32
- id: s.id,
33
- name: String(s.name ?? ""),
34
- type: String(s.type ?? ""),
35
- teamId: (typeof s.team_id === "string" ? s.team_id : typeof s.teamId === "string" ? s.teamId : teamId) ?? null,
36
- });
37
- };
38
- const body = res.body;
39
- if (Array.isArray(body)) {
40
- for (const s of body)
41
- push(s, null);
42
- }
43
- else if (body && typeof body === "object") {
44
- const teams = body.teams;
45
- if (Array.isArray(teams)) {
46
- for (const t of teams) {
47
- const teamId = typeof t.teamId === "string" ? t.teamId : typeof t.id === "string" ? t.id : typeof t.team_id === "string" ? t.team_id : null;
48
- const states = (t.states ?? t.stages);
49
- if (Array.isArray(states))
50
- for (const s of states)
51
- push(s, teamId);
52
- }
53
- }
54
- const stages = body.stages ?? body.states;
55
- if (Array.isArray(stages))
56
- for (const s of stages)
57
- push(s, null);
58
- }
59
- return out;
60
- }
61
- /** The first state of `type` on `teamId`, in the order the route lists them. */
62
- export function firstStateOfType(states, teamId, type) {
63
- const hit = states.find((s) => s.teamId === teamId && s.type.toLowerCase() === type.toLowerCase());
64
- if (!hit) {
65
- const seen = [...new Set(states.filter((s) => s.teamId === teamId).map((s) => s.type))].join(", ") || "none";
66
- throw new CliError(`team ${teamId} has no state of type "${type}" (types seen: ${seen})`, "state-type-unknown");
67
- }
68
- return hit;
69
- }
70
- /** Post to a contract route by name; a budget refusal names the contract's daily budget. */
71
- export async function postAgent(api, doc, routeName, body) {
72
- try {
73
- const res = await api.postJson(routePath(doc, routeName), body);
74
- return res.body;
75
- }
76
- catch (err) {
77
- if (err instanceof CliError && err.code === "budget") {
78
- throw new CliError(`${err.message} — the contract's thresholds.hostDailyWriteBudget is ${doc.thresholds.hostDailyWriteBudget} writes per host per day`, "budget", 2, 429);
79
- }
80
- throw err;
81
- }
82
- }
83
- export async function cmdWrite(args, ctx, deps = {}) {
84
- const [sub, ...rest] = positionals(args);
85
- if (!sub)
86
- throw new UsageError("write needs a subcommand: comment | state | label | create | reaction | attachment | session");
87
- const cfg = requireConfig(ctx);
88
- const { doc } = await loadContract(ctx, cfg);
89
- const api = apiClient(cfg, ctx);
90
- const asUser = flagBool(args, "as-user");
91
- let result;
92
- switch (sub) {
93
- case "comment": {
94
- const ticket = needTicket(rest, "comment <ticket>");
95
- let body = flagString(args, "body");
96
- if (flagBool(args, "stdin"))
97
- body = (await (deps.readStdin ?? readStdin)()).trimEnd();
98
- if (!body)
99
- throw new UsageError("write comment needs --body <text> or --stdin");
100
- if (flagBool(args, "bookkeeping"))
101
- body = `${bookkeepingPrefix(doc)} ${body}`;
102
- const issue = await resolveIssue(api, ticket);
103
- result = await postAgent(api, doc, "issue-comment", {
104
- issueId: issue.id,
105
- body,
106
- ...(flagString(args, "parent") ? { parentId: flagString(args, "parent") } : {}),
107
- ...(asUser ? { createAsUser: true } : {}),
108
- });
109
- break;
110
- }
111
- case "state": {
112
- const ticket = needTicket(rest, "state <ticket>");
113
- const issue = await resolveIssue(api, ticket);
114
- const slot = flagString(args, "slot");
115
- const stateId = flagString(args, "state-id");
116
- const stateType = flagString(args, "state-type");
117
- let target;
118
- if (stateId)
119
- target = stateId;
120
- else if (slot)
121
- target = stageIdForSlot(teamForTicket(doc, issue.identifier), slot);
122
- else if (stateType) {
123
- const team = teamForTicket(doc, issue.identifier);
124
- target = firstStateOfType(await fetchWorkflowStates(api), issue.teamId ?? team.id, stateType).id;
125
- }
126
- else
127
- throw new UsageError("write state needs --slot <slot>, --state-id <id> or --state-type <type>");
128
- result = await postAgent(api, doc, "issue-state", { issueId: issue.id, stateId: target });
129
- break;
130
- }
131
- case "label": {
132
- const ticket = needTicket(rest, "label <ticket>");
133
- const add = flagList(args, "add");
134
- const remove = flagList(args, "remove");
135
- if (add.length === 0 && remove.length === 0)
136
- throw new UsageError("write label needs --add <name|id> and/or --remove <name|id>");
137
- const issue = await resolveIssue(api, ticket);
138
- const team = teamForTicket(doc, issue.identifier);
139
- const results = [];
140
- if (add.length)
141
- results.push(await postAgent(api, doc, "issue-label", { issueId: issue.id, labelIds: add.map((n) => labelId(team, n)), mode: "add" }));
142
- if (remove.length)
143
- results.push(await postAgent(api, doc, "issue-label", { issueId: issue.id, labelIds: remove.map((n) => labelId(team, n)), mode: "remove" }));
144
- result = results.length === 1 ? results[0] : results;
145
- break;
146
- }
147
- case "create": {
148
- const teamKey = flagString(args, "team");
149
- const title = flagString(args, "title");
150
- if (!teamKey || !title)
151
- throw new UsageError("write create needs --team <key> and --title <text>");
152
- const team = teamByKey(doc, teamKey);
153
- const labels = flagList(args, "label").map((n) => labelId(team, n));
154
- const priority = flagString(args, "priority") !== undefined ? flagInt(args, "priority", 0) : undefined;
155
- result = await postAgent(api, doc, "issue-create", {
156
- teamId: team.id,
157
- title,
158
- ...(labels.length ? { labelIds: labels } : {}),
159
- ...(priority !== undefined ? { priority } : {}),
160
- ...(asUser ? { createAsUser: true } : {}),
161
- });
162
- break;
163
- }
164
- case "reaction": {
165
- const emoji = flagString(args, "emoji");
166
- if (!emoji)
167
- throw new UsageError("write reaction needs --emoji <e>");
168
- const commentId = flagString(args, "comment");
169
- const body = { emoji, ...(asUser ? { createAsUser: true } : {}) };
170
- if (commentId)
171
- body.commentId = commentId;
172
- else
173
- body.issueId = (await resolveIssue(api, needTicket(rest, "reaction <ticket>|--comment <id>"))).id;
174
- result = await postAgent(api, doc, "reaction", body);
175
- break;
176
- }
177
- case "attachment": {
178
- const ticket = needTicket(rest, "attachment <ticket>");
179
- const title = flagString(args, "title");
180
- const url = flagString(args, "url");
181
- if (!title || !url)
182
- throw new UsageError("write attachment needs --title <t> and --url <u>");
183
- const issue = await resolveIssue(api, ticket);
184
- result = await postAgent(api, doc, "attachment", { issueId: issue.id, title, url });
185
- break;
186
- }
187
- case "session": {
188
- const ticket = needTicket(rest, "session <ticket>");
189
- const issue = await resolveIssue(api, ticket);
190
- const planFile = flagString(args, "plan-file");
191
- const plan = planFile ? JSON.parse(readFileSync(planFile, "utf8")) : undefined;
192
- result = await postAgent(api, doc, "session", {
193
- issueId: issue.id,
194
- ...(flagString(args, "title") ? { title: flagString(args, "title") } : {}),
195
- ...(plan !== undefined ? { plan } : {}),
196
- ...(flagString(args, "activity") ? { activity: flagString(args, "activity") } : {}),
197
- ...(flagString(args, "url") ? { url: flagString(args, "url") } : {}),
198
- });
199
- break;
200
- }
201
- case "delegate":
202
- throw new UsageError("write delegate is not offered: delegation is an operator action, not a tenant write");
203
- default:
204
- throw new UsageError(`unknown write subcommand: ${sub}`);
205
- }
206
- ctx.stdout(args.json ? JSON.stringify(result ?? {}) : describeWrite(sub, result));
207
- return 0;
208
- }
209
- function needTicket(rest, usage) {
210
- const t = rest[0];
211
- if (!t)
212
- throw new UsageError(`write ${usage}`);
213
- return t;
214
- }
215
- function describeWrite(sub, result) {
216
- const id = result && typeof result === "object" ? result.id ?? result.commentId ?? result.issueId : undefined;
217
- return `${sub}: ok${id ? ` (${String(id)})` : ""}`;
218
- }
219
- async function readStdin() {
220
- const chunks = [];
221
- for await (const chunk of process.stdin)
222
- chunks.push(Buffer.from(chunk));
223
- return Buffer.concat(chunks).toString("utf8");
224
- }
@@ -1,35 +0,0 @@
1
- ---
2
- name: catalyst-github
3
- description: >-
4
- Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
- allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
- ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills — written in this repository for customer tenants -->
8
-
9
- # Catalyst's GitHub
10
-
11
- You answer for the pull requests Catalyst opens and moves. The person asking wants to see one PR, know what is red, and know whether it will merge. Everything tenant-specific (the reviewer's login, the merge policy per repository, the four PR label names, the required checks) comes from the contract at run time and is printed by the scripts; you never restate it from memory.
12
-
13
- ## Run first
14
-
15
- - `node scripts/read-pr.mjs --help` — a ticket's PR (or a PR by node id): state, branch, head, linked ticket, GitHub's mergeable state, every check, status and review. `--all` lists every PR for a ticket.
16
- - `node scripts/is-it-mergeable.mjs --help` — the three legs (checks, reviewer signal, unresolved threads) judged under the repository's policy, plus the prerequisites; exit 1 when a leg is red.
17
-
18
- Both read through `catalyst-skills query`, `contract` and, when fresh, `replica`; the first stderr line names the source, and your answer repeats it.
19
-
20
- ## Load on demand
21
-
22
- | when | read |
23
- | -- | -- |
24
- | the person asks what Catalyst did to the branch or PR, why the title changed, why history was rewritten, what a label means, or what happens on merge | `references/what-a-pr-accumulates.md` |
25
- | the person asks why a PR has not merged, what the queue needs, what a clean review pass looks like, or what a policy requires | `references/is-it-mergeable.md` |
26
-
27
- ## Rules
28
-
29
- - Never compose a URL or call the API yourself. Every read is a `catalyst-skills` verb behind a script; run the script, do not read it.
30
- - A queue-ready label is the cloud's attestation, never a lever you apply; "merged by the queue" is the terminal signal.
31
- - Hold labels are the human's lever. Describe them; do not apply or remove them.
32
- - Say what a key cannot see by name (PR labels, the reviewer's reaction, the queue's own state); never guess it.
33
- - An inconclusive leg is not a red one. Report "not proven from this read" and name what would settle it.
34
- - Never poll. To wait on a merge, subscribe through the project-running skill's watch; do not re-run a script in a loop.
35
- - Cite the PR as `<owner/name>#<number>` and the ticket by its identifier.
@@ -1,6 +0,0 @@
1
- interface:
2
- display_name: "Catalyst's GitHub"
3
- short_description: "Show a ticket's pull request with its checks, reviews and threads, and say whether it is mergeable under this repository's policy"
4
- default_prompt: "Use $catalyst-github to show me the PR for this ticket and whether it can merge."
5
- policy:
6
- allow_implicit_invocation: true
@@ -1,4 +0,0 @@
1
- identity: { pack: catalyst-cloud-skills, skill: catalyst-github }
2
- effects: []
3
- invocation: implicit
4
- exposure: [catalog]
@@ -1,57 +0,0 @@
1
- # Is it mergeable?
2
-
3
- The rule is invariant; the policy that applies to a repository is not. The policy names, the default, each repository's resolved policy and its source (repository setting, tenant setting, or the default), the reviewer login and the required check names all come from the contract's `merge` block at run time. `node scripts/is-it-mergeable.mjs <ticket>` reads them and prints one line per leg; the text below explains what those lines mean.
4
-
5
- ## Prerequisites before any leg counts
6
-
7
- A merge queue never takes a PR that is closed, still a draft, or in conflict with its base branch, and neither does the cloud's evaluator. The script reports these first: the pr phase is what marks a draft ready for review; a conflict (GitHub reports the mergeable state as dirty) is repaired by a remediate round that rebases the branch.
8
-
9
- ## The three legs
10
-
11
- Merge evidence has three independent legs. Two are unconditional under every policy; the third is what the policy decides.
12
-
13
- | leg | passes when | always required? |
14
- | -- | -- | -- |
15
- | checks | every gating check at the current head completed with a non-failing conclusion (success, neutral or skipped count as non-failing). A pending check, or an empty set, is unknown, never green. The queue's own status check is not evidence about the PR and is excluded. | yes |
16
- | reviewer | the reviewer signal the policy accepts (below) | only under a reviewer-attestation policy |
17
- | threads | zero unresolved review threads, and no resolved thread whose resolving commit has left the head | yes |
18
-
19
- Which checks are gating: the names the queue configuration itself requires, plus any extra gating check the cloud names. The contract serves the checks the cloud's own remediation waits on as `cloudRemediateRequiredChecks`; a check that cannot block a merge is informational and never a reason to withhold the label.
20
-
21
- ## The policies
22
-
23
- Three policy names are served on `merge.policies`; the default is `merge.defaultPolicy`; a repository's own is `merge.repositories[].policy` with `policySource` saying where it came from. Read them from the contract; do not assume a repository is on the default.
24
-
25
- | policy as served today | reviewer leg passes when |
26
- | -- | -- |
27
- | the default (an attestation policy) | a clean pass at the current head, or findings raised on an earlier head together with green checks and zero unresolved threads. In plain terms: the reviewer found something, the fix landed, every thread it raised is resolved, and the checks are green at the current head. That PR re-earns eligibility on its own; nobody has to ask the reviewer again. |
28
- | the strict variant | a clean pass at the current head, always. Findings on an earlier head keep blocking until the reviewer looks again. |
29
- | checks and threads only | the reviewer requirement is dropped entirely; the two unconditional legs decide. |
30
-
31
- A clean pass is a reaction: a thumbs-up from the reviewer login posted at or after the head commit's own timestamp. The comment-shaped clean pass ("no major issues" in place of threads) is honoured only if `cleanPassShapes[]` says so for that kind; the script does not judge it either way.
32
-
33
- ## What the script can and cannot see
34
-
35
- The mirrored PR detail carries checks, legacy commit statuses, review objects with their state, GitHub's mergeable state and the auto-merge flag. It does not carry reactions, PR labels, or the commit each review was submitted against. So:
36
-
37
- - checks: judged fully from the detail.
38
- - reviewer: reported as inconclusive unless the policy waives it. The script says whether the reviewer left review objects, but whether a clean-pass reaction followed, and whether those reviews stand at the current head or an earlier one, is not mirrored.
39
- - threads: read from the local replica's review-thread rows when the replica is fresh (resolved flag per thread); inconclusive otherwise, with the command that starts the replica. Thread ancestry against force-pushes is judged by the cloud, not here.
40
-
41
- The exit code is 1 only for a proven-red leg or prerequisite. An inconclusive leg leaves exit 0 and a verdict that says "not proven mergeable from this read". The cloud's own evaluator, which holds the reaction and the ancestry, is the authority; a queue-ready label on the PR means it already said yes.
42
-
43
- ## What happens after the label
44
-
45
- Once the cloud's merge phase applies the queue-ready label, four events each wake one bounded remediate round with no operator in the loop: a required check turning red, a fresh conflict with the base branch, a non-clean review, and a dequeue by the queue. The round repairs what it can, pushes, and the PR re-enters the queue itself. A scheduled sweep is the independent backstop for a labelled PR that is not moving. A hand-posted review request is never the required unblock for the ordinary shape.
46
-
47
- ## The levers a human has
48
-
49
- - The hold label keeps an eligible PR out of the queue; removing it lets the PR enter on its own.
50
- - The preview label requests a preview and holds the merge; removing it is the approval.
51
- - Replying on a review thread and re-resolving it re-attests that thread against the current head.
52
- - A comment on the ticket releases a remediate round that changed nothing, and a validate hold.
53
- - A path the queue excludes is merged by hand; the cloud marks such a PR with the hand-steps hold automatically.
54
-
55
- ## Reading the answer
56
-
57
- Lead with the verdict line, then the red legs with what unblocks each, then the inconclusive ones with what would settle them. Name the policy and where it came from. Say which source the read used (the first stderr line). If the person wants to wait for the merge, subscribe through the project-running skill's watch rather than re-running this script in a loop.
@@ -1,61 +0,0 @@
1
- # What a pull request accumulates as Catalyst moves a ticket
2
-
3
- Most of this page restates invariants: how Catalyst names branches, opens and rewrites pull requests, and what it pushes when. The parts that vary per tenant are read live from the contract's `merge` block and never restated here: the reviewer's login (`merge.reviewerLogin`), which clean-pass shapes are honoured (`merge.cleanPassShapes[]`), the four PR label names (`merge.prLabels`), the checks the cloud waits on (`merge.cloudRemediateRequiredChecks`), and the policy per repository (`merge.repositories[]`, else `merge.defaultPolicy`). `catalyst-skills contract --path merge` prints them.
4
-
5
- ## The branch
6
-
7
- The branch is named exactly the ticket identifier, with no prefix: ticket `ABC-123` works on branch `ABC-123`. It is created by the implement phase once that phase has changes to push. A ticket that has never reached implement has no branch, which is why an eligibility explanation can say a branch is missing: nothing is wrong, nothing has run yet.
8
-
9
- A separate work-in-progress namespace exists for runner scratch pushes; a pull request is never opened from it.
10
-
11
- ## The pull request, phase by phase
12
-
13
- | phase | what it does to the PR |
14
- | -- | -- |
15
- | implement | pushes the branch and opens a draft PR with a placeholder. The title is Conventional-Commit shaped: `feat(<scopes>): <ticket> — <ticket title>`, scopes derived from the changed top-level app or package directories (at most three), the whole title capped at 110 characters. The body says the description is pending from the pr phase. |
16
- | validate | runs the gate and writes its report; touches the PR only through the checks the push triggers. |
17
- | pr | writes the real title and body from its own artifact (first line the title, then the body), rebases the branch onto the current base, force-pushes, updates the PR, and marks it ready for review. |
18
- | remediate | repairs one failure class (a red check, a conflict, review findings, a dequeue) and pushes with a compare-and-swap force push, so a push that raced it is never overwritten. |
19
- | merge | evaluates the evidence below and, when it is sufficient, applies the queue-ready label. It performs no merge itself. |
20
-
21
- No `Closes <ticket>` trailer is inserted. The ticket reaches Done through the merge webhook, not through any phase (see the last section).
22
-
23
- ## Force-pushes are routine
24
-
25
- Three phases rewrite history on the ticket branch: implement pushes fresh with force, pr rebases and force-pushes, remediate force-pushes with a lease. Two consequences a reader must hold:
26
-
27
- - A review thread resolved against one commit may stop being evidence once the head moves. Catalyst walks the PR's own force-push timeline: a rebase that carries the same fix forward still counts; a force-push that dropped the fix does not, and the refusal names the thread and both commits. Replying on the thread and re-resolving it re-attests against the current head.
28
- - A check result belongs to a head. After any push, the checks at the new head start from nothing; "no check has reported yet" is unknown, never green.
29
-
30
- ## The reviewer signal
31
-
32
- An automated reviewer (login from the contract) reviews every PR. Its signal is one of four: clean, findings at the current head, findings at an earlier head, no review. A clean pass is a reaction, not a review object: a thumbs-up from the reviewer posted at or after the head commit's own timestamp. A second shape, a terse "no major issues" comment in place of review threads, exists as a documented convention; whether it is honoured is served on `cleanPassShapes[].honoured` and must be read there, never assumed. What counts under each policy is on `references/is-it-mergeable.md`.
33
-
34
- ## Labels on the PR
35
-
36
- Four labels matter, all named on `merge.prLabels`, all matched exactly (a queue's label conditions are exact matches, which is why every hold has its own name):
37
-
38
- | contract field | who applies it | meaning |
39
- | -- | -- | -- |
40
- | `queueReady` | the cloud's merge phase, once its own evidence gate passes | an attestation that the evidence was sufficient. It is not a lever: applying it by hand makes nothing merge that would not have merged, and removing it is not a hold. |
41
- | `hold` | a human | the one manual escape hatch. An eligible PR stays out of the queue while it carries this. |
42
- | `handStepsHold` | the cloud, automatically | the PR touches a path the queue configuration excludes (schema or migration paths, typically), so a person has to merge it by hand. |
43
- | `preview` | a human | "deploy me a preview and hold the merge until I have looked". Removing the label is the approval. |
44
-
45
- The mirror does not carry PR labels or reactions, so your key cannot read which holds a PR carries or whether the reviewer reacted; the scripts say so by name. GitHub's own page, or the tenant's settings, is where those live.
46
-
47
- ## The queue is optional and opt-out
48
-
49
- When a repository runs a merge queue, entry is opt-out: a PR against the base branch that is green on its required checks, not a draft, has zero unresolved review threads, carries no hold label, and touches no excluded path enters the queue on its own. Nobody applies a label to make that happen. The terminal signal is the queue's own merge ("merged by" the queue bot), not any return value from a merge command. Without a queue, the queue-ready label is still the cloud's attestation and the merge itself is whatever the repository's own setup does with it.
50
-
51
- ## After the label
52
-
53
- A problem after the attestation does not wait on a person noticing. A red required check, a fresh conflict with the base branch, a non-clean review, or a dequeue by the queue each wakes one bounded remediate round automatically, which repairs what it can, re-pushes, and re-enters the queue itself. An independent scheduled sweep remains as a backstop for a PR that carries the label and is going nowhere. Hand-posting a review request to the reviewer is never the required unblock for the ordinary remediation shape.
54
-
55
- ## What happens on merge
56
-
57
- The ticket moves to Done when the real merge webhook arrives, keyed to the pull request's merged event, with a bounded sweep as the backstop for a dropped webhook. Measured latency is a few seconds. A phase never writes Done; a ticket still not Done a minute after its PR merged is a finding, not a chore. Done is not the same as live: a change to a service deploys on its own pipeline after the merge.
58
-
59
- ## Reading a PR through this skill
60
-
61
- `node scripts/read-pr.mjs <ticket>` follows the ticket's own record to the pull requests that name it, picks the open one (else the merged one, else the newest), and prints the mirrored detail: state, draft and merged flags, branch and base, head commit, the linked ticket and its stage, GitHub's own mergeable state and auto-merge flag, every check with status and conclusion, legacy commit statuses, and every mirrored review with its state. `--all` lists every PR for the ticket; `--json` prints the raw document. The first stderr line names the source, a fresh replica or the API, and the answer should repeat it.
@@ -1,124 +0,0 @@
1
- #!/usr/bin/env node
2
- // is-it-mergeable.mjs — the three legs of merge evidence for one PR (checks, reviewer signal,
3
- // unresolved threads) judged under the policy the tenant contract resolves for its repository, plus
4
- // the prerequisites a queue applies before any leg counts (open, not a draft, no conflict). Every
5
- // input comes from `catalyst-skills query`, `contract` and, when fresh, `replica sql`.
6
- // Exit 0 no leg is red, 1 a leg or prerequisite is red, 2 this machine is not connected.
7
- import { parseJson, runCli, runCliOrExit } from "./lib/cli.mjs";
8
- import { resolvePull, truthy } from "./lib/pull.mjs";
9
-
10
- const HELP = `Usage: node scripts/is-it-mergeable.mjs <ticket | pr-node-id> [--json]
11
-
12
- Judges one pull request against the merge policy the tenant contract resolves for its repository:
13
- checks every gating check at the head completed without failing (pending or absent is not green)
14
- reviewer the reviewer signal the policy requires; the clean-pass reaction itself is not mirrored,
15
- so this leg is reported as inconclusive rather than guessed, unless the policy waives it
16
- threads zero unresolved review threads, read from the local replica when it is fresh
17
-
18
- A red prerequisite (closed, draft, merge conflict) is also exit 1. Inconclusive legs never fail the
19
- exit code; the cloud's own evaluator, which holds the reaction and the thread ancestry, is authoritative.
20
- --json prints {pr, policy, legs[], verdict} instead of lines.`;
21
-
22
- const args = process.argv.slice(2);
23
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
24
- console.log(HELP);
25
- process.exit(0);
26
- }
27
- const json = args.includes("--json");
28
- const target = args.find((a) => !a.startsWith("--"));
29
- if (!target) {
30
- console.error("is-it-mergeable needs a ticket or a PR node id");
31
- process.exit(1);
32
- }
33
-
34
- const { ticket, detail } = resolvePull(target);
35
- const merge = parseJson(runCliOrExit(["contract", "--path", "merge", "--json"]).stdout);
36
- if (!merge || !Array.isArray(merge.policies)) {
37
- console.error("the contract's merge block did not parse; run: catalyst-skills contract --refresh");
38
- process.exit(1);
39
- }
40
-
41
- const repoId = String(detail.repo_id ?? "");
42
- const repoRow = (merge.repositories ?? []).find((r) => `${r.owner}/${r.name}`.toLowerCase() === repoId.toLowerCase());
43
- const policy = repoRow?.policy ?? merge.defaultPolicy;
44
- const policySource = repoRow ? repoRow.policySource : "default";
45
- const requiresReviewer = policy !== "checks-and-threads";
46
- const acceptsEarlierHead = policy === "codex-attestation";
47
-
48
- const legs = [];
49
- const add = (leg, state, line) => legs.push({ leg, state, line });
50
-
51
- // ── prerequisites the queue applies before any leg counts ──────────────────────────────────────
52
- if (truthy(detail.merged)) {
53
- add("state", "pass", `already merged${detail.merged_at ? ` at ${new Date(Number(detail.merged_at)).toISOString()}` : ""}; nothing left to judge`);
54
- } else {
55
- const state = String(detail.state ?? "").toLowerCase();
56
- if (state && state !== "open") add("state", "fail", `the PR is ${state}, not open`);
57
- else if (truthy(detail.draft)) add("state", "fail", "the PR is still a draft; the pr phase marks it ready for review, a queue never takes a draft");
58
- else add("state", "pass", "open and ready for review");
59
- const ms = String(detail.mergeable_state ?? "").toLowerCase();
60
- if (ms === "dirty" || detail.mergeable === false || detail.mergeable === 0) add("conflict", "fail", `GitHub reports a merge conflict with the base branch (mergeable_state=${ms || "unknown"}); a remediate round rebases it`);
61
- else if (ms) add("conflict", "pass", `GitHub reports mergeable_state=${ms}`);
62
- else add("conflict", "inconclusive", "GitHub has not reported a mergeable state for this head yet");
63
- }
64
-
65
- // ── leg 1: checks ───────────────────────────────────────────────────────────────────────────────
66
- const checks = Array.isArray(detail.checks) ? detail.checks : [];
67
- const required = Array.isArray(merge.cloudRemediateRequiredChecks) ? merge.cloudRemediateRequiredChecks : [];
68
- const notQueue = checks.filter((c) => !/mergify|^queue:|^summary$/i.test(String(c.name ?? "")));
69
- const gating = notQueue.filter((c) => required.includes(String(c.name)));
70
- const judged = gating.length > 0 ? gating : notQueue;
71
- const ok = new Set(["success", "neutral", "skipped"]);
72
- const red = judged.filter((c) => String(c.status).toLowerCase() === "completed" && !ok.has(String(c.conclusion).toLowerCase()));
73
- const pending = judged.filter((c) => String(c.status).toLowerCase() !== "completed");
74
- const statuses = Array.isArray(detail.commit_statuses) ? detail.commit_statuses : [];
75
- const redStatuses = statuses.filter((s) => ["failure", "error"].includes(String(s.state).toLowerCase()));
76
- const scope = gating.length > 0 ? `the ${gating.length} required check(s) the contract names` : `every mirrored check (none of the contract's required names is present at this head)`;
77
- if (judged.length === 0 && redStatuses.length === 0) add("checks", "inconclusive", "no check has reported at this head; an unrun set is unknown, never green");
78
- else if (red.length > 0 || redStatuses.length > 0) add("checks", "fail", `red: ${[...red.map((c) => `${c.name} (${c.conclusion})`), ...redStatuses.map((s) => `${s.context} (${s.state})`)].join(", ")}`);
79
- else if (pending.length > 0) add("checks", "inconclusive", `still running: ${pending.map((c) => c.name).join(", ")} — judged over ${scope}`);
80
- else add("checks", "pass", `green over ${scope}`);
81
-
82
- // ── leg 2: the reviewer signal ─────────────────────────────────────────────────────────────────
83
- const reviews = Array.isArray(detail.reviews) ? detail.reviews : [];
84
- const login = String(merge.reviewerLogin ?? "");
85
- const byReviewer = reviews.filter((r) => String(r.reviewer_name ?? "") === login || String(r.reviewer_id ?? "") === `github:${login}`);
86
- if (!requiresReviewer) add("reviewer", "pass", `policy ${policy} needs no reviewer signal`);
87
- else if (byReviewer.length > 0) {
88
- const states = [...new Set(byReviewer.map((r) => String(r.state ?? "?")))].join(", ");
89
- add("reviewer", "inconclusive", `${login} left ${byReviewer.length} review object(s) (${states}); whether they stand at the current head or an earlier one, and whether a clean-pass reaction followed, is not mirrored${acceptsEarlierHead ? " — under this policy, findings on an earlier head plus green checks and zero unresolved threads are accepted" : " — this policy requires a clean pass at the current head"}`);
90
- } else add("reviewer", "inconclusive", `no review object from ${login} is mirrored; a clean pass is a reaction, which this read cannot see`);
91
-
92
- // ── leg 3: unresolved threads, from the replica when it is fresh ───────────────────────────────
93
- const replica = parseJson(runCli(["replica", "status", "--json"]).stdout);
94
- if (replica && replica.verdict === "fresh") {
95
- const num = Number(detail.number);
96
- const esc = repoId.replace(/'/g, "''");
97
- const sql = `select resolved, count(*) as n from pr_review_threads where repo_id = '${esc}' and pr_number = ${Number.isInteger(num) ? num : -1} group by resolved`;
98
- const rows = parseJson(runCli(["replica", "sql", sql, "--json"]).stdout) ?? [];
99
- const count = (want) => rows.filter((r) => (want === null ? r.resolved === null || r.resolved === undefined : Number(r.resolved) === want)).reduce((a, r) => a + Number(r.n ?? 0), 0);
100
- const unresolved = count(0);
101
- const unknown = count(null);
102
- if (unresolved > 0) add("threads", "fail", `${unresolved} unresolved review thread(s)`);
103
- else if (unknown > 0) add("threads", "inconclusive", `${unknown} thread(s) with no resolved flag mirrored`);
104
- else add("threads", "pass", `${count(1)} thread(s), all resolved (ancestry against force-pushes is judged by the cloud, not here)`);
105
- } else {
106
- add("threads", "inconclusive", `the PR detail carries no thread count and the replica is ${replica?.verdict ?? "unavailable"}; start it (catalyst-skills replica start --detach) for a local read`);
107
- }
108
-
109
- const failed = legs.filter((l) => l.state === "fail");
110
- const open = legs.filter((l) => l.state === "inconclusive");
111
- let verdict;
112
- if (failed.length > 0) verdict = `NOT MERGEABLE: ${failed.map((l) => l.leg).join(", ")}`;
113
- else if (open.length > 0) verdict = `NOT PROVEN MERGEABLE from this read (${open.map((l) => l.leg).join(", ")} inconclusive); the cloud's evaluator decides`;
114
- else verdict = "MERGEABLE as far as this read can see";
115
-
116
- const header = `${repoId}#${detail.number ?? "?"}${ticket ? ` (${ticket})` : ""} head ${String(detail.head_sha ?? "").slice(0, 12)} — policy ${policy} (${policySource})`;
117
- if (json) {
118
- console.log(JSON.stringify({ pr: { repo_id: repoId, number: detail.number, node_id: detail.node_id, ticket, head_sha: detail.head_sha }, policy: { name: policy, source: policySource }, legs, verdict }));
119
- } else {
120
- console.log(header);
121
- for (const l of legs) console.log(`${l.state === "pass" ? "ok " : l.state === "fail" ? "FAIL" : "? "} ${l.leg}: ${l.line}`);
122
- console.log(verdict);
123
- }
124
- process.exit(failed.length > 0 ? 1 : 0);
@@ -1,103 +0,0 @@
1
- #!/usr/bin/env node
2
- // lib/cli.mjs — the one way a skill script reaches the Catalyst Cloud SDK and API: by spawning the
3
- // catalyst-skills CLI this bundle installed. It reads customer.json to find that CLI and nothing
4
- // else; it never holds the key itself. This file is a library — run a sibling script with
5
- // --help for usage. Identical in every skill of this bundle on purpose (skills install one directory
6
- // at a time, so nothing shared outside the skill would ever be installed).
7
- import { spawnSync } from "node:child_process";
8
- import { existsSync, readFileSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
12
-
13
- export const PACKAGE = "@catalyst-cloud/catalyst-skills";
14
- export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
15
-
16
- /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
17
- export function configPath() {
18
- const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
19
- return join(home, ".config", "catalyst-cloud", "customer.json");
20
- }
21
-
22
- /** The customer config, or exit 2 with one line naming the connect command. Never throws. */
23
- export function requireCustomerConfig() {
24
- const path = configPath();
25
- if (!existsSync(path)) {
26
- console.error(CONNECT_HINT);
27
- process.exit(2);
28
- }
29
- let cfg;
30
- try {
31
- cfg = JSON.parse(readFileSync(path, "utf8"));
32
- } catch (err) {
33
- console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
34
- process.exit(2);
35
- }
36
- if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
37
- console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
38
- process.exit(2);
39
- }
40
- return cfg;
41
- }
42
-
43
- /**
44
- * Run one catalyst-skills verb and return {code, stdout, stderr}. Spawns the CLI whose path the
45
- * connect step recorded in customer.json; falls back to `npx @catalyst-cloud/catalyst-skills` when
46
- * no path is recorded or it no longer exists. Exits 2 when the machine is not connected.
47
- */
48
- export function runCli(args, opts = {}) {
49
- const cfg = requireCustomerConfig();
50
- const recorded = typeof cfg.cliPath === "string" && existsSync(cfg.cliPath);
51
- const command = recorded ? process.execPath : process.platform === "win32" ? "npx.cmd" : "npx";
52
- const argv = recorded ? [cfg.cliPath, ...args] : [PACKAGE, ...args];
53
- const res = spawnSync(command, argv, {
54
- encoding: "utf8",
55
- input: opts.stdin,
56
- env: process.env,
57
- maxBuffer: 64 * 1024 * 1024,
58
- shell: !recorded && process.platform === "win32",
59
- });
60
- if (res.error) {
61
- console.error(`could not run ${recorded ? cfg.cliPath : `npx ${PACKAGE}`}: ${res.error.message}`);
62
- process.exit(2);
63
- }
64
- return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
65
- }
66
-
67
- /**
68
- * Run a verb that must succeed. On exit 2 (the CLI's "not configured / refused" class) the script
69
- * exits 2; on any other non-zero exit it exits 1. The CLI's own stderr is forwarded either way so the
70
- * reason is never lost.
71
- */
72
- export function runCliOrExit(args, opts = {}) {
73
- const res = runCli(args, opts);
74
- if (res.code === 0) return res;
75
- const why = res.stderr.trim() || res.stdout.trim() || `catalyst-skills ${args.join(" ")} exited ${res.code}`;
76
- console.error(why);
77
- process.exit(res.code === 2 ? 2 : 1);
78
- }
79
-
80
- /** Parse the CLI's --json stdout; null when it is not JSON (the caller decides what that means). */
81
- export function parseJson(stdout) {
82
- try {
83
- return JSON.parse(stdout);
84
- } catch {
85
- return null;
86
- }
87
- }
88
-
89
- /** Forward the `source: replica|api (...)` line a query verb prints, so the answer names its source. */
90
- export function forwardSourceLine(res) {
91
- for (const line of res.stderr.split("\n")) {
92
- if (line.startsWith("source:")) console.error(line);
93
- }
94
- }
95
-
96
- /** True for a ticket identifier such as ABC-123; false for a GitHub node id or anything else. */
97
- export function looksLikeTicket(s) {
98
- return /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(s);
99
- }
100
-
101
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
102
- console.log("lib/cli.mjs is a library used by the scripts beside it; run any of those with --help for usage.");
103
- }