@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +109 -53
- package/dist/args.js +237 -0
- package/dist/ask.js +137 -0
- package/dist/cli.js +165 -279
- package/dist/config.js +106 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +178 -0
- package/dist/detach.js +10 -0
- package/dist/errors.js +27 -0
- package/dist/execution.js +156 -0
- package/dist/http.js +142 -0
- package/dist/prompt.js +35 -0
- package/dist/query.js +187 -0
- package/dist/ready.js +123 -0
- package/dist/replica.js +318 -0
- package/dist/sdk.js +26 -0
- package/dist/skill-shape.js +179 -0
- package/dist/skills.js +74 -0
- package/dist/ts-deps-loader.js +68 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +173 -0
- package/dist/write.js +224 -0
- package/package.json +9 -5
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-setup/SKILL.md +32 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +55 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +184 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +42 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +47 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
- package/skills/whats-happening/scripts/snapshot.mjs +145 -0
- package/skills/ask/SKILL.md +0 -37
- package/skills/concierge/SKILL.md +0 -41
- package/skills/join/SKILL.md +0 -30
- package/skills/linearis/SKILL.md +0 -40
- package/skills/setup/SKILL.md +0 -27
- package/skills/steward/SKILL.md +0 -30
package/dist/watch.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// watch.ts — `watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file] [--from]`:
|
|
2
|
+
// one JSON line per in-scope change frame, cursor advanced after the reaction, over the SDK's live
|
|
3
|
+
// stream. Scope is filtered on the client from the frame's entity and row; a project scope resolves
|
|
4
|
+
// an issue reference to its project through GET /api/v1/issues/:ref, cached in memory.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { flagList, flagString } from "./args.js";
|
|
7
|
+
import { apiBase, requireConfig, watchCursorPathFor } from "./config.js";
|
|
8
|
+
import { loadContract } from "./contract.js";
|
|
9
|
+
import { CliError, UsageError } from "./errors.js";
|
|
10
|
+
import { apiClient } from "./http.js";
|
|
11
|
+
import { loadSdk } from "./sdk.js";
|
|
12
|
+
import { createLiveEventsClient } from "./watch/consumer.js";
|
|
13
|
+
import { CursorFileError } from "./watch/cursor-file.js";
|
|
14
|
+
const ROW_ISSUE_REFS = ["identifier", "linear_issue_identifier", "issue_identifier", "issue_id", "linear_issue_id"];
|
|
15
|
+
/** Decide whether one frame falls inside the scope. An empty scope admits everything. Rows that only
|
|
16
|
+
* carry an issue reference (comments, sessions, PRs) resolve it through `resolve` for project scope. */
|
|
17
|
+
export async function inScope(frame, scope, resolve) {
|
|
18
|
+
const hasScope = Boolean(scope.teamId || scope.teamKey || (scope.tickets && scope.tickets.length) || scope.projectId);
|
|
19
|
+
if (!hasScope)
|
|
20
|
+
return true;
|
|
21
|
+
const row = frame.row ?? {};
|
|
22
|
+
const refs = ROW_ISSUE_REFS.map((k) => row[k]).filter((v) => typeof v === "string" && v !== "");
|
|
23
|
+
const upperTickets = (scope.tickets ?? []).map((t) => t.toUpperCase());
|
|
24
|
+
if (upperTickets.length > 0) {
|
|
25
|
+
if (refs.some((r) => upperTickets.includes(r.toUpperCase())))
|
|
26
|
+
return true;
|
|
27
|
+
for (const r of refs) {
|
|
28
|
+
const info = await resolve(r);
|
|
29
|
+
if (info?.identifier && upperTickets.includes(info.identifier.toUpperCase()))
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (scope.projectId) {
|
|
34
|
+
if (row.project_id === scope.projectId || row.id === scope.projectId)
|
|
35
|
+
return true;
|
|
36
|
+
for (const r of refs) {
|
|
37
|
+
const info = await resolve(r);
|
|
38
|
+
if (info?.projectId === scope.projectId)
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (scope.teamId || scope.teamKey) {
|
|
43
|
+
if (scope.teamId && (row.team_id === scope.teamId || row.teamId === scope.teamId))
|
|
44
|
+
return true;
|
|
45
|
+
if (scope.teamKey && refs.some((r) => /^[A-Za-z]+-\d+$/.test(r) && r.toUpperCase().startsWith(`${scope.teamKey.toUpperCase()}-`)))
|
|
46
|
+
return true;
|
|
47
|
+
for (const r of refs) {
|
|
48
|
+
const info = await resolve(r);
|
|
49
|
+
if (info && scope.teamId && info.teamId === scope.teamId)
|
|
50
|
+
return true;
|
|
51
|
+
if (info?.identifier && scope.teamKey && info.identifier.toUpperCase().startsWith(`${scope.teamKey.toUpperCase()}-`))
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/** Resolve issue references through the API, cached per process. */
|
|
58
|
+
export function apiIssueResolver(cfg, ctx) {
|
|
59
|
+
const api = apiClient(cfg, ctx);
|
|
60
|
+
const cache = new Map();
|
|
61
|
+
return async (ref) => {
|
|
62
|
+
if (cache.has(ref))
|
|
63
|
+
return cache.get(ref);
|
|
64
|
+
let out = null;
|
|
65
|
+
try {
|
|
66
|
+
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ref)}`, { accept: [404] });
|
|
67
|
+
if (res.status !== 404 && res.body) {
|
|
68
|
+
out = {
|
|
69
|
+
id: typeof res.body.id === "string" ? res.body.id : null,
|
|
70
|
+
identifier: typeof res.body.identifier === "string" ? res.body.identifier : null,
|
|
71
|
+
projectId: typeof res.body.project_id === "string" ? res.body.project_id : null,
|
|
72
|
+
teamId: typeof res.body.team_id === "string" ? res.body.team_id : null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
out = null;
|
|
78
|
+
}
|
|
79
|
+
cache.set(ref, out);
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Run `--exec CMD` once with the frame on stdin; a non-zero exit is a failed reaction.
|
|
85
|
+
*
|
|
86
|
+
* The child's EXIT CODE is the whole contract, and a broken pipe is not a failure on its own. A
|
|
87
|
+
* handler that exits before reading the frame is ordinary and often correct — `grep -q`, `head -1`,
|
|
88
|
+
* a script that fails fast — and so is one that simply died. In every one of those cases the write
|
|
89
|
+
* lands on a pipe with nothing at the other end and raises EPIPE asynchronously. Node delivers that
|
|
90
|
+
* as an `error` event on the child's stdin, and a stream with no `error` listener rethrows it as an
|
|
91
|
+
* unhandled error, which takes down the whole watch: one misbehaving handler and the customer stops
|
|
92
|
+
* receiving their tenant's events. So the pipe is allowed to break, and the exit code still decides.
|
|
93
|
+
*/
|
|
94
|
+
export function execReaction(cmd, env) {
|
|
95
|
+
return (frame) => new Promise((resolve, reject) => {
|
|
96
|
+
const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "inherit", "inherit"], env });
|
|
97
|
+
child.on("error", reject);
|
|
98
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`--exec exited ${code}`))));
|
|
99
|
+
// Listen before writing: attaching afterwards races the synchronous part of `end()`.
|
|
100
|
+
child.stdin.on("error", () => { });
|
|
101
|
+
try {
|
|
102
|
+
if (child.stdin.writable)
|
|
103
|
+
child.stdin.end(`${JSON.stringify(frame)}\n`);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// A stdin already destroyed throws here rather than emitting; same verdict, wait for close.
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export async function runWatch(ctx, cfg, opts, deps = {}) {
|
|
111
|
+
const sdk = deps.sdk ?? (await loadSdk());
|
|
112
|
+
const resolve = deps.resolver ?? apiIssueResolver(cfg, ctx);
|
|
113
|
+
const react = opts.exec ? execReaction(opts.exec, ctx.env) : undefined;
|
|
114
|
+
let handle;
|
|
115
|
+
try {
|
|
116
|
+
handle = buildHandle();
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err instanceof CursorFileError)
|
|
120
|
+
throw new CliError(`${err.message} (${err.path})`, "cursor-file");
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
deps.onHandle?.(handle);
|
|
124
|
+
const stopped = deps.waitForStop ??
|
|
125
|
+
(() => new Promise((done) => {
|
|
126
|
+
process.once("SIGINT", () => done());
|
|
127
|
+
process.once("SIGTERM", () => done());
|
|
128
|
+
}));
|
|
129
|
+
const running = handle.client.start();
|
|
130
|
+
await stopped();
|
|
131
|
+
handle.client.stop();
|
|
132
|
+
await running;
|
|
133
|
+
return 0;
|
|
134
|
+
function buildHandle() {
|
|
135
|
+
return createLiveEventsClient(sdk, {
|
|
136
|
+
baseUrl: apiBase(cfg),
|
|
137
|
+
accountId: cfg.account,
|
|
138
|
+
token: cfg.key,
|
|
139
|
+
cursorFile: opts.cursorFile ?? watchCursorPathFor(ctx.home),
|
|
140
|
+
fromHead: opts.fromHead,
|
|
141
|
+
fetchImpl: ctx.fetch,
|
|
142
|
+
wsFactory: deps.wsFactory,
|
|
143
|
+
backoffMs: deps.backoffMs,
|
|
144
|
+
maxBackoffMs: deps.maxBackoffMs,
|
|
145
|
+
log: (level, msg) => ctx.stderr(`[watch ${level}] ${msg}`),
|
|
146
|
+
onStatus: (s) => ctx.stderr(`[watch] status=${s}`),
|
|
147
|
+
onReseed: (cursor) => ctx.stderr(`[watch] resync: cursor moved to head ${cursor}`),
|
|
148
|
+
onEvent: async (frame) => {
|
|
149
|
+
if (!(await inScope(frame, opts.scope, resolve)))
|
|
150
|
+
return;
|
|
151
|
+
ctx.stdout(JSON.stringify(frame));
|
|
152
|
+
if (react)
|
|
153
|
+
await react(frame);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export async function cmdWatch(args, ctx, deps = {}) {
|
|
159
|
+
const cfg = requireConfig(ctx);
|
|
160
|
+
const from = flagString(args, "from") ?? "cursor";
|
|
161
|
+
if (from !== "cursor" && from !== "head")
|
|
162
|
+
throw new UsageError("--from must be cursor or head");
|
|
163
|
+
const scope = { tickets: flagList(args, "ticket"), projectId: flagString(args, "project") };
|
|
164
|
+
const teamKey = flagString(args, "team");
|
|
165
|
+
if (teamKey) {
|
|
166
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
167
|
+
const team = doc.teams.find((t) => (t.key ?? "").toUpperCase() === teamKey.toUpperCase());
|
|
168
|
+
scope.teamKey = teamKey;
|
|
169
|
+
if (team)
|
|
170
|
+
scope.teamId = team.id;
|
|
171
|
+
}
|
|
172
|
+
return runWatch(ctx, cfg, { scope, exec: flagString(args, "exec"), cursorFile: flagString(args, "cursor-file"), fromHead: from === "head" }, deps);
|
|
173
|
+
}
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
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 "./http.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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/catalyst-skills",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Customer skill bundle for
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Customer skill bundle for Catalyst Cloud: eight skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, catalyst-setup, catalyst-linear, catalyst-github, how-catalyst-works, connect-me) plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"url": "https://github.com/coalesce-labs/catalyst-cloud-skills/issues"
|
|
17
17
|
},
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": ">=
|
|
19
|
+
"node": ">=22"
|
|
20
20
|
},
|
|
21
21
|
"bin": {
|
|
22
22
|
"catalyst-skills": "bin/catalyst-skills.js"
|
|
@@ -30,14 +30,18 @@
|
|
|
30
30
|
"LICENSE"
|
|
31
31
|
],
|
|
32
32
|
"catalystCloud": {
|
|
33
|
-
"tenantContractRange": "
|
|
33
|
+
"tenantContractRange": "1.x"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"typecheck": "tsc --noEmit",
|
|
37
37
|
"build": "tsc -p tsconfig.build.json",
|
|
38
38
|
"prepack": "tsc -p tsconfig.build.json",
|
|
39
39
|
"test": "vitest run",
|
|
40
|
-
"test:coverage": "vitest run --coverage"
|
|
40
|
+
"test:coverage": "vitest run --coverage",
|
|
41
|
+
"version:sync": "node scripts/sync-plugin-version.mjs"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@catalyst-cloud/sdk": "^0.8.49"
|
|
41
45
|
},
|
|
42
46
|
"devDependencies": {
|
|
43
47
|
"@types/node": "^26.3.0",
|
|
@@ -0,0 +1,35 @@
|
|
|
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.
|
|
@@ -0,0 +1,6 @@
|
|
|
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
|
|
@@ -0,0 +1,57 @@
|
|
|
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.
|
|
@@ -0,0 +1,61 @@
|
|
|
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 an account 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.
|