@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
|
@@ -0,0 +1,124 @@
|
|
|
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);
|
|
@@ -0,0 +1,102 @@
|
|
|
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 account 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
|
+
|
|
12
|
+
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
13
|
+
export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<account key> npx ${PACKAGE} login`;
|
|
14
|
+
|
|
15
|
+
/** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
|
|
16
|
+
export function configPath() {
|
|
17
|
+
const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
18
|
+
return join(home, ".config", "catalyst-cloud", "customer.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The customer config, or exit 2 with one line naming the connect command. Never throws. */
|
|
22
|
+
export function requireCustomerConfig() {
|
|
23
|
+
const path = configPath();
|
|
24
|
+
if (!existsSync(path)) {
|
|
25
|
+
console.error(CONNECT_HINT);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
let cfg;
|
|
29
|
+
try {
|
|
30
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
if (!cfg || typeof cfg !== "object" || typeof cfg.key !== "string" || typeof cfg.baseUrl !== "string") {
|
|
36
|
+
console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
return cfg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run one catalyst-skills verb and return {code, stdout, stderr}. Spawns the CLI whose path the
|
|
44
|
+
* connect step recorded in customer.json; falls back to `npx @catalyst-cloud/catalyst-skills` when
|
|
45
|
+
* no path is recorded or it no longer exists. Exits 2 when the machine is not connected.
|
|
46
|
+
*/
|
|
47
|
+
export function runCli(args, opts = {}) {
|
|
48
|
+
const cfg = requireCustomerConfig();
|
|
49
|
+
const recorded = typeof cfg.cliPath === "string" && existsSync(cfg.cliPath);
|
|
50
|
+
const command = recorded ? process.execPath : process.platform === "win32" ? "npx.cmd" : "npx";
|
|
51
|
+
const argv = recorded ? [cfg.cliPath, ...args] : [PACKAGE, ...args];
|
|
52
|
+
const res = spawnSync(command, argv, {
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
input: opts.stdin,
|
|
55
|
+
env: process.env,
|
|
56
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
57
|
+
shell: !recorded && process.platform === "win32",
|
|
58
|
+
});
|
|
59
|
+
if (res.error) {
|
|
60
|
+
console.error(`could not run ${recorded ? cfg.cliPath : `npx ${PACKAGE}`}: ${res.error.message}`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run a verb that must succeed. On exit 2 (the CLI's "not configured / refused" class) the script
|
|
68
|
+
* exits 2; on any other non-zero exit it exits 1. The CLI's own stderr is forwarded either way so the
|
|
69
|
+
* reason is never lost.
|
|
70
|
+
*/
|
|
71
|
+
export function runCliOrExit(args, opts = {}) {
|
|
72
|
+
const res = runCli(args, opts);
|
|
73
|
+
if (res.code === 0) return res;
|
|
74
|
+
const why = res.stderr.trim() || res.stdout.trim() || `catalyst-skills ${args.join(" ")} exited ${res.code}`;
|
|
75
|
+
console.error(why);
|
|
76
|
+
process.exit(res.code === 2 ? 2 : 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse the CLI's --json stdout; null when it is not JSON (the caller decides what that means). */
|
|
80
|
+
export function parseJson(stdout) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(stdout);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Forward the `source: replica|api (...)` line a query verb prints, so the answer names its source. */
|
|
89
|
+
export function forwardSourceLine(res) {
|
|
90
|
+
for (const line of res.stderr.split("\n")) {
|
|
91
|
+
if (line.startsWith("source:")) console.error(line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True for a ticket identifier such as ABC-123; false for a GitHub node id or anything else. */
|
|
96
|
+
export function looksLikeTicket(s) {
|
|
97
|
+
return /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(s);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
101
|
+
console.log("lib/cli.mjs is a library used by the scripts beside it; run any of those with --help for usage.");
|
|
102
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/pull.mjs — resolve "a ticket or a PR id" to one mirrored pull-request detail, the way both
|
|
3
|
+
// scripts in this skill need it. A ticket resolves through its own record's linked pull requests
|
|
4
|
+
// (the mirror links a PR to the ticket its text names), then the chosen PR's detail is read by node
|
|
5
|
+
// id. A library: run read-pr.mjs or is-it-mergeable.mjs with --help for usage.
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { forwardSourceLine, looksLikeTicket, parseJson, runCli, runCliOrExit } from "./cli.mjs";
|
|
8
|
+
|
|
9
|
+
export function truthy(v) {
|
|
10
|
+
return v === true || v === 1 || v === "1" || v === "true";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readIssue(ticket, extra = []) {
|
|
14
|
+
const res = runCli(["query", "issue", ticket, ...extra, "--json"]);
|
|
15
|
+
forwardSourceLine(res);
|
|
16
|
+
if (res.code !== 0) {
|
|
17
|
+
console.error(res.stderr.trim() || `${ticket}: not found`);
|
|
18
|
+
process.exit(res.code === 2 ? 2 : 1);
|
|
19
|
+
}
|
|
20
|
+
const issue = parseJson(res.stdout);
|
|
21
|
+
if (!issue || typeof issue !== "object") {
|
|
22
|
+
console.error(`${ticket}: the ticket read did not return JSON`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
return issue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every PR that names the ticket, newest number first. Exits 1 when the ticket is unknown. A source
|
|
30
|
+
* whose ticket detail carries no `linked_pulls` field at all (a replica built by an SDK that predates
|
|
31
|
+
* the ticket-to-PR link) is not evidence of "no PR": the ticket is re-read from the API, which is
|
|
32
|
+
* origin-fresh and carries the field, and the second source line says so. An empty list from a source
|
|
33
|
+
* that has the field is a real "no PR yet".
|
|
34
|
+
*/
|
|
35
|
+
export function linkedPulls(ticket) {
|
|
36
|
+
let issue = readIssue(ticket);
|
|
37
|
+
if (issue.linked_pulls === undefined) {
|
|
38
|
+
console.error("note: this source carries no linked pull requests on a ticket; re-reading the ticket from the API");
|
|
39
|
+
issue = readIssue(ticket, ["--source", "api"]);
|
|
40
|
+
}
|
|
41
|
+
const pulls = Array.isArray(issue.linked_pulls) ? issue.linked_pulls : [];
|
|
42
|
+
return { issue, pulls: [...pulls].sort((a, b) => Number(b.number ?? 0) - Number(a.number ?? 0)) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The PR that matters for a ticket: an open one first, else the merged one, else the newest. */
|
|
46
|
+
export function pickPull(pulls) {
|
|
47
|
+
const open = pulls.find((p) => String(p.state ?? "").toLowerCase() === "open" && !truthy(p.merged));
|
|
48
|
+
if (open) return open;
|
|
49
|
+
const merged = pulls.find((p) => truthy(p.merged));
|
|
50
|
+
return merged ?? pulls[0] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One PR's detail by node id: checks, reviews, commit statuses, mergeable state, linked ticket. */
|
|
54
|
+
export function pullDetail(nodeId) {
|
|
55
|
+
const res = runCliOrExit(["query", "pull", nodeId, "--json"]);
|
|
56
|
+
forwardSourceLine(res);
|
|
57
|
+
const detail = parseJson(res.stdout);
|
|
58
|
+
if (!detail || typeof detail !== "object") {
|
|
59
|
+
console.error(`${nodeId}: the pull read did not return JSON`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
return detail;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ticket or node id → {ticket, detail, linked}. Exits 1 when a ticket has no PR yet. */
|
|
66
|
+
export function resolvePull(arg) {
|
|
67
|
+
if (looksLikeTicket(arg)) {
|
|
68
|
+
const { pulls } = linkedPulls(arg);
|
|
69
|
+
const chosen = pickPull(pulls);
|
|
70
|
+
if (!chosen) {
|
|
71
|
+
console.error(`${arg}: no pull request names this ticket yet — the implement phase opens one when it has a branch`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
return { ticket: arg, detail: pullDetail(String(chosen.node_id)), linked: pulls };
|
|
75
|
+
}
|
|
76
|
+
const detail = pullDetail(arg);
|
|
77
|
+
return { ticket: detail.linear_issue_identifier ? String(detail.linear_issue_identifier) : null, detail, linked: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
81
|
+
console.log("lib/pull.mjs is a library used by read-pr.mjs and is-it-mergeable.mjs; run those with --help for usage.");
|
|
82
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// read-pr.mjs — one ticket's pull request (or one PR by node id) as the mirror holds it: state,
|
|
3
|
+
// branch, head, base, linked ticket and its stage, GitHub's own mergeable verdict, every check,
|
|
4
|
+
// review and commit status. Reads through `catalyst-skills query`; composes no URL.
|
|
5
|
+
import { linkedPulls, resolvePull, truthy } from "./lib/pull.mjs";
|
|
6
|
+
|
|
7
|
+
const HELP = `Usage: node scripts/read-pr.mjs <ticket | pr-node-id> [--all] [--json]
|
|
8
|
+
|
|
9
|
+
<ticket> a ticket identifier such as ABC-123: its open PR is shown (else the merged one, else the newest)
|
|
10
|
+
<pr-node-id> a GitHub pull-request node id, as printed by "catalyst-skills query pulls"
|
|
11
|
+
--all for a ticket: list every PR that names it instead of one detail
|
|
12
|
+
--json print the raw detail document instead of the summary
|
|
13
|
+
|
|
14
|
+
Exit 0 shown, 1 not found or no PR yet, 2 this machine is not connected to a tenant.
|
|
15
|
+
The first stderr line names the source the CLI read from (a fresh replica, or the API).`;
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
19
|
+
console.log(HELP);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
const json = args.includes("--json");
|
|
23
|
+
const all = args.includes("--all");
|
|
24
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
25
|
+
if (!target) {
|
|
26
|
+
console.error("read-pr needs a ticket or a PR node id");
|
|
27
|
+
console.log(HELP);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fmt(v, fallback = "?") {
|
|
32
|
+
return v === null || v === undefined || v === "" ? fallback : String(v);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function when(ms) {
|
|
36
|
+
const n = Number(ms);
|
|
37
|
+
return Number.isFinite(n) && n > 0 ? new Date(n).toISOString() : fmt(ms, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (all) {
|
|
41
|
+
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(target)) {
|
|
42
|
+
console.error("--all takes a ticket identifier");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const { pulls } = linkedPulls(target);
|
|
46
|
+
if (json) {
|
|
47
|
+
console.log(JSON.stringify(pulls));
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
if (pulls.length === 0) {
|
|
51
|
+
console.log(`${target}: no pull request names this ticket yet`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
for (const p of pulls) {
|
|
55
|
+
const flags = [truthy(p.draft) ? "draft" : null, truthy(p.merged) ? "merged" : null].filter(Boolean).join(" ");
|
|
56
|
+
console.log(`${fmt(p.repo_id)}#${fmt(p.number)} ${fmt(p.state)}${flags ? ` ${flags}` : ""} ${fmt(p.title, "")} [${fmt(p.node_id, "")}]`);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { ticket, detail } = resolvePull(target);
|
|
62
|
+
if (json) {
|
|
63
|
+
console.log(JSON.stringify(detail));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const checks = Array.isArray(detail.checks) ? detail.checks : [];
|
|
68
|
+
const reviews = Array.isArray(detail.reviews) ? detail.reviews : [];
|
|
69
|
+
const statuses = Array.isArray(detail.commit_statuses) ? detail.commit_statuses : [];
|
|
70
|
+
const state = [fmt(detail.state), truthy(detail.draft) ? "draft" : null, truthy(detail.merged) ? `merged ${when(detail.merged_at)}` : null].filter(Boolean).join(", ");
|
|
71
|
+
|
|
72
|
+
console.log(`${fmt(detail.repo_id)}#${fmt(detail.number)} ${fmt(detail.title, "(no title yet)")}`);
|
|
73
|
+
console.log(`state: ${state} by ${fmt(detail.author_login)} opened ${when(detail.created_at)} updated ${when(detail.updated_at)}`);
|
|
74
|
+
console.log(`branch: ${fmt(detail.head_ref)} → ${fmt(detail.base_ref)} head ${fmt(detail.head_sha).slice(0, 12)}`);
|
|
75
|
+
console.log(`ticket: ${fmt(ticket ?? detail.linear_issue_identifier, "none named")}${detail.linked_issue_state ? ` (stage: ${detail.linked_issue_state})` : ""}`);
|
|
76
|
+
console.log(`github says: mergeable=${fmt(detail.mergeable, "unknown")} state=${fmt(detail.mergeable_state, "unknown")} auto-merge=${truthy(detail.auto_merge) ? "on" : "off"}`);
|
|
77
|
+
if (detail.blocked_on_ask && typeof detail.blocked_on_ask === "object") {
|
|
78
|
+
const b = detail.blocked_on_ask;
|
|
79
|
+
console.log(`blocked on an ask: ${fmt(b.identifier ?? b.id, "")} ${fmt(b.title, "")}`.trim());
|
|
80
|
+
}
|
|
81
|
+
console.log(`node id: ${fmt(detail.node_id)}`);
|
|
82
|
+
|
|
83
|
+
console.log(`checks (${checks.length}):`);
|
|
84
|
+
if (checks.length === 0) console.log(" none reported at this head yet");
|
|
85
|
+
for (const c of checks) console.log(` ${fmt(c.status)}/${fmt(c.conclusion, "-")} ${fmt(c.name)}`);
|
|
86
|
+
|
|
87
|
+
if (statuses.length > 0) {
|
|
88
|
+
console.log(`commit statuses (${statuses.length}):`);
|
|
89
|
+
for (const s of statuses) console.log(` ${fmt(s.state)} ${fmt(s.context)}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`reviews (${reviews.length}):`);
|
|
93
|
+
if (reviews.length === 0) console.log(" none mirrored; a reviewer's clean pass is a reaction, which this read does not carry");
|
|
94
|
+
for (const r of reviews) console.log(` ${fmt(r.state)} ${fmt(r.reviewer_name, fmt(r.reviewer_id))} ${when(r.submitted_at)}`);
|
|
95
|
+
|
|
96
|
+
console.log("not visible to an account key here: PR labels (holds, queue attestation) and the reviewer's reaction — see references/what-a-pr-accumulates.md");
|
|
97
|
+
process.exit(0);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: catalyst-linear
|
|
3
|
+
description:
|
|
4
|
+
Catalyst's view of Linear on the customer's own tenant. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the local replica when it is fresh and the origin-fresh API otherwise, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the tenant's agent proxy as the app actor, with every route, stage id, label id and marker read from the tenant contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
|
|
5
|
+
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
|
+
disable-model-invocation: true
|
|
7
|
+
---
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills — written in this repository for customer tenants -->
|
|
9
|
+
|
|
10
|
+
# Catalyst Linear
|
|
11
|
+
|
|
12
|
+
You read tickets and write to them on the customer's tenant, as Catalyst. Reads come from the replica when it is fresh, else from the API, and every read names its source. Writes go through the tenant's agent proxy as the app actor, with routes, stage ids, label ids and the bookkeeping marker resolved from the tenant contract by the `catalyst-skills` CLI. You never compose a URL, never name a stage by its display name, and never quote a label id or marker from memory.
|
|
13
|
+
|
|
14
|
+
## Run first
|
|
15
|
+
|
|
16
|
+
Run each with `--help` first; scripts are executed, never read.
|
|
17
|
+
|
|
18
|
+
- `node scripts/read-ticket.mjs <ticket> [--comments]` — one ticket, everything inline, source line on stderr.
|
|
19
|
+
- `node scripts/search.mjs <terms>` — tickets, PRs, projects, initiatives matching the terms.
|
|
20
|
+
- `node scripts/comment.mjs <ticket> --body <text> [--parent <id>] [--bookkeeping]` — a comment as the app actor; a machine record takes `--bookkeeping`.
|
|
21
|
+
- `node scripts/move.mjs <ticket> --slot <slot>` — a card move by slot (`--state-type backlog` parks).
|
|
22
|
+
- `node scripts/label.mjs <ticket> --add <name> --remove <name>` — labels, resolved through the contract.
|
|
23
|
+
- `node scripts/create-ticket.mjs --team <key> --title <text>` — a new ticket; cite its identifier only after it prints.
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 done, 1 not found or a usage error, 2 this machine is not connected or the write was refused (budget spent, slot unmapped, label absent; the one line printed says which).
|
|
26
|
+
|
|
27
|
+
## Load on demand
|
|
28
|
+
|
|
29
|
+
| when | read |
|
|
30
|
+
| -- | -- |
|
|
31
|
+
| "what did Catalyst write on this ticket?", a comment shape, a label, the marker, the 👀 | `references/what-a-ticket-accumulates.md` |
|
|
32
|
+
| before any read you will report on; "is this current?"; citing; searching | `references/reading-a-ticket.md` |
|
|
33
|
+
| before any comment, move, label or new ticket; identity, budget, slots, ids | `references/writing-to-linear.md` |
|
|
34
|
+
|
|
35
|
+
## Rules
|
|
36
|
+
|
|
37
|
+
- **Read before acting.** Never summarise a ticket from its title; read the description and the thread. Cite identifiers and comment ids only after a script printed them.
|
|
38
|
+
- **Name the source.** Quote the source line when freshness matters; a stale replica is never read silently, because the CLI falls back to the API and says so.
|
|
39
|
+
- **Reply where it arrived, as Catalyst.** Thread under the comment you answer (`--parent`); never post as the human.
|
|
40
|
+
- **Records carry the marker.** A merge note, a state-move log, a chain summary: `--bookkeeping`, so it never reads as a human turn. A real question is not bookkeeping.
|
|
41
|
+
- **Move by slot, label by contract.** A stage name is display; the id is the authority, and the CLI resolves both. Moving to `dispatch` dispatches; the backlog-type state parks.
|
|
42
|
+
- **A decision is an ask, not a ticket.** Raise it through `what-needs-me` with options, a default and what it blocks; a question filed as a ticket is held out of dispatch by shape.
|
|
43
|
+
- **One write per need, no loops.** Writes spend a daily budget the contract names; batch, and report a refusal rather than retrying.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Catalyst Linear"
|
|
3
|
+
short_description: "Read a ticket and what Catalyst wrote on it; comment, move, label and file tickets as the app actor"
|
|
4
|
+
default_prompt: "Use $catalyst-linear to show me the ticket, its history, and what Catalyst wrote on it."
|
|
5
|
+
policy:
|
|
6
|
+
allow_implicit_invocation: false
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Reading a ticket: freshness first, then the row
|
|
2
|
+
|
|
3
|
+
This reference restates invariants about how a read is made and reported. Nothing in it is a tenant value.
|
|
4
|
+
|
|
5
|
+
## Freshness first
|
|
6
|
+
|
|
7
|
+
Every `query` verb, and therefore `node scripts/read-ticket.mjs` and `node scripts/search.mjs`, prints one stderr line before the answer:
|
|
8
|
+
|
|
9
|
+
- `source: replica (cursor N)` — the local replica was fresh (its writer heartbeat is young and its cursor non-empty), so the answer is local and cheap. N is the position it had applied.
|
|
10
|
+
- `source: api (replica stale)`, `source: api (replica absent)` or `source: api (replica not configured)` — the answer came from the tenant API, which is origin-fresh: it reflects the mirror's latest ingest, not a cached copy.
|
|
11
|
+
- `source: api (<verb> is api-only)` — search, cycles, pull detail and change feeds are never served from the replica.
|
|
12
|
+
|
|
13
|
+
Read that line every time. A fresh replica is a preference; the API is the fallback and it is never wrong to read it. What is wrong is silently reading a stale replica, and the CLI does not let that happen: a stale replica falls back to the API and says so. Quote the source in your answer when the freshness of a fact matters ("as of the replica at cursor N" or "from the API just now").
|
|
14
|
+
|
|
15
|
+
`--source replica` or `--source api` forces one. Forcing the replica when it is absent or stale is refused.
|
|
16
|
+
|
|
17
|
+
## Then the row
|
|
18
|
+
|
|
19
|
+
One ticket read returns the whole record: the fields, the description, `labels[]`, `relations[]`, `linked_pulls[]`, `comments[]`, `activity[]` and `agent_sessions[]` inline, plus the project, cycle, team, delegate and parent fields. One call answers "what is this ticket, what happened on it, what is it waiting on, which PR is it". There is no second call to make for the comments.
|
|
20
|
+
|
|
21
|
+
The summary the script prints puts the counts first and the description last; `--comments` prints every comment with its id, author, time and whether the author is a bot. A comment with a `reply-to` marker is threaded under another; reply under the same parent when you answer it.
|
|
22
|
+
|
|
23
|
+
## Reading Catalyst's own writes
|
|
24
|
+
|
|
25
|
+
The comment shapes in `references/what-a-ticket-accumulates.md` tell you which comments are the cloud's. To answer "what happened to this ticket":
|
|
26
|
+
|
|
27
|
+
1. Phase-outcome comments, newest first, give the attempts and their results.
|
|
28
|
+
2. Remediate-attempt comments give the failure class each round repaired.
|
|
29
|
+
3. The projection-link comments name the documents; open the document (it is attached to the ticket) to read what a phase actually concluded. A fallback comment carries the body inline instead.
|
|
30
|
+
4. `linked_pulls[]` names the PR; `catalyst-github` reads it.
|
|
31
|
+
5. For "what will it do next", leave this skill: `how-catalyst-works` explains the eligibility row.
|
|
32
|
+
|
|
33
|
+
## When to read a transcript
|
|
34
|
+
|
|
35
|
+
A phase's transcript (the full session log) exists in the cloud per ticket, but this bundle has no verb for it yet. Read the artifact document first; it is the phase's own account of what it did. Reach for the transcript only when the document leaves the question open, and say that the bundle cannot fetch it so the human opens it from the ticket's attachments.
|
|
36
|
+
|
|
37
|
+
## What an account key cannot read
|
|
38
|
+
|
|
39
|
+
Per-ticket execution history beyond the comments (the attempt ledger, the remediation round count against the cap, park state and what releases it) is not readable with an account key yet; `catalyst-skills explain --history <ticket>` prints that by name and where to read it. Do not reconstruct a round count from comments and present it as the cap's count.
|
|
40
|
+
|
|
41
|
+
## How to cite
|
|
42
|
+
|
|
43
|
+
- Cite a ticket by its identifier (`KEY-123`), never by title alone, and only after you read it back from a script's output.
|
|
44
|
+
- Cite a comment by its id when you refer to it or reply under it.
|
|
45
|
+
- Cite a document by the title the projection comment gave it.
|
|
46
|
+
- Never summarise a ticket from its title. Read the description and the thread.
|
|
47
|
+
|
|
48
|
+
## Searching and listing
|
|
49
|
+
|
|
50
|
+
`node scripts/search.mjs <terms>` matches ticket identifiers and titles, PR titles, project and initiative names and returns a few of each. It is the only search; a list read with a filter is not a search and will hand back the ordinary first page, which reads as a false "not found". Lists (`catalyst-skills query issues --team <key>`, `query projects`, `query cycles`) are for a board view, not for finding one ticket.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# What a ticket accumulates as Catalyst works it
|
|
2
|
+
|
|
3
|
+
This reference restates invariants: the shapes of what the cloud writes onto a ticket. The one live value it needs — the bookkeeping marker and the label names — is on the contract under `vocabulary` and `teams[].labels`; print them with `catalyst-skills contract --path vocabulary` and `catalyst-skills contract --path teams`. Never quote a marker or a label id from memory.
|
|
4
|
+
|
|
5
|
+
## Comments the cloud posts
|
|
6
|
+
|
|
7
|
+
| Kind | When | How it opens |
|
|
8
|
+
| -- | -- | -- |
|
|
9
|
+
| Phase outcome | every phase attempt ends | `✅ **Phase complete**` or `🛑 **Phase FAILED**`, then `**Phase**`, an optional `**Attempt**`, `**Artifact**`, a quoted summary, and a park, hold or failure block when there is one; a footer names the event that produced it |
|
|
10
|
+
| Remediate attempt | every remediation round ends | `🔧 **Remediate attempt N — SUCCEEDED**` or `FAILED`, then `**Class**` (the failure class being repaired) and optionally `**Repairing**` and `**Artifact**` |
|
|
11
|
+
| Projection link | an artifact document was created in Linear | `**<phase>** · attempt N — [<title>](<document url>)`, then the storage key and the thoughts-repository path |
|
|
12
|
+
| Projection fallback | Linear refused a document | `> ⚠️ **Fallback comment.** Linear refused a document for this artifact: <reason>` followed by the artifact body fenced inline |
|
|
13
|
+
| Board health | a ticket sat in an active stage with no sign of life | `**Catalyst Cloud · board health**`, what stalled, and "Action taken" |
|
|
14
|
+
| Merge wait | a hold at PR or merge named a cause | says what the merge is waiting on |
|
|
15
|
+
| Ask reply | a human answered an ask | a threaded reply under the answer |
|
|
16
|
+
|
|
17
|
+
Every one of these is posted by the app actor and is skipped by the comment-wake trigger; none of them wakes an agent.
|
|
18
|
+
|
|
19
|
+
## One document per phase, attached
|
|
20
|
+
|
|
21
|
+
Each artifact-bearing phase (research, plan, implement, validate, pr, remediate) projects its artifact into Linear as a document at a deterministic id, titled `<TICKET> · <phase> · attempt <n> · <YYYY-MM-DD>`, attached to the ticket, and announced by the projection-link comment above. Research and plan documents on a ticket that belongs to a project are also linked from the project. The same body is committed into the tenant's thoughts repository when one is configured. Reading the document is how you read what a phase concluded; the phase-outcome comment only summarises it.
|
|
22
|
+
|
|
23
|
+
## The agent session
|
|
24
|
+
|
|
25
|
+
A ticket being worked grows a Linear agent session whose **plan** is the ladder itself: phases before the current one completed, the current one in progress, later ones pending. A remediation round is an interrupt with no ladder position and anchors on the phase it interrupted. Activities record a phase starting, the gate running, an artifact being written, a PR being opened, and a terminal report. A session in a pending, active or awaiting-input status is reused; a stale, complete or errored one is replaced by a fresh session. Emission is best-effort: a failure to narrate never blocks a phase.
|
|
26
|
+
|
|
27
|
+
## Labels
|
|
28
|
+
|
|
29
|
+
| Role (contract name) | Who applies it | Meaning |
|
|
30
|
+
| -- | -- | -- |
|
|
31
|
+
| ask marker (`vocabulary.askMarkerLabel`, listed under `teams[].labels.ask`) | the cloud, atomically when it files an ask; or a human filing one by hand | this ticket is a question for a human, never work |
|
|
32
|
+
| ask kind family (`vocabulary.askLabelPrefix`, and per-phase `vocabulary.askPhaseLabelPrefix`) | the cloud, or a human | what kind of ask, and which phase raised it |
|
|
33
|
+
| release (`vocabulary.releaseLabel`, listed under `teams[].labels.release`) | **a human only** | releases a ticket the shape detector flagged as an ask; a human-applied ask-kind label outranks it |
|
|
34
|
+
| hold (listed under `teams[].labels.hold`) | the cloud | the marker a team gets instead of a Remediate state move when it has no remediate stage mapped |
|
|
35
|
+
| a local-lane marker | the cloud | a worker outside the cloud holds this ticket; removed at that lane's terminal step |
|
|
36
|
+
|
|
37
|
+
`(absent)` beside a label in `teams[].labels` means the workspace has no such label yet; the CLI refuses to apply a name it cannot resolve.
|
|
38
|
+
|
|
39
|
+
## Relations
|
|
40
|
+
|
|
41
|
+
`blocks` is the only relation Catalyst creates. It is written when an ask is raised, so the ask blocks every ticket waiting on the answer, and it is the signal that ranks the human's inbox: an ask with nothing to block is refused unless the caller declares nothing is blocked, because such an ask would never surface. No other relation type is written by the cloud.
|
|
42
|
+
|
|
43
|
+
## The bookkeeping marker
|
|
44
|
+
|
|
45
|
+
A comment that is a machine RECORD (a merge note, a state-move log, a chain summary) rather than a turn in a conversation must begin with the bookkeeping marker from `vocabulary.bookkeeping.marker`. Rules the contract states alongside it: it is a prefix only, never a substring; ASCII case-insensitive; leading whitespace is trimmed first. A marked comment never wakes an agent and never counts as "something happened" for the no-change and validate holds.
|
|
46
|
+
|
|
47
|
+
It matters on the human-attributed path: a record posted with a personal identity is wire-identical to the human typing it and would wake an agent to reply to its own writeup. A comment posted as the app actor already carries a bot actor and is already skipped; `--bookkeeping` on the comment script is still correct there, and harmless.
|
|
48
|
+
|
|
49
|
+
A real question that merely mentions the word is not bookkeeping and still wakes, deliberately.
|
|
50
|
+
|
|
51
|
+
## The eyes acknowledgement
|
|
52
|
+
|
|
53
|
+
When a genuine human comment lands (not a bot, not agent-authored, not an automation signature, not bookkeeping), the cloud reacts with 👀 and records a durable wake; the record is written even if the reaction fails. When a bot or agent reply observed at ingest is threaded under that comment, the 👀 is removed and the wake resolves. No live session is required for either half. So: reply in-thread, under the comment you are answering, or the acknowledgement never clears.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Writing to Linear: identity, budget, slots, labels
|
|
2
|
+
|
|
3
|
+
This reference restates the write mechanism. The live values it depends on — the route table, the daily write budget, the stage map, the label ids — are read by the CLI from the contract (`routes`, `thresholds`, `teams[].stages`, `teams[].labels`) on every write. Nothing here is a literal to copy.
|
|
4
|
+
|
|
5
|
+
## Every write is one script
|
|
6
|
+
|
|
7
|
+
| Want | Run |
|
|
8
|
+
| -- | -- |
|
|
9
|
+
| a comment, threaded or top-level | `node scripts/comment.mjs <ticket> --body <text> [--parent <commentId>] [--bookkeeping]` |
|
|
10
|
+
| a card move | `node scripts/move.mjs <ticket> --slot <slot>` or `--state-type backlog` |
|
|
11
|
+
| a label on or off | `node scripts/label.mjs <ticket> --add <name> --remove <name>` |
|
|
12
|
+
| a new ticket | `node scripts/create-ticket.mjs --team <key> --title <text>` |
|
|
13
|
+
| a decision for a human | not here: the `what-needs-me` skill raises an ask with options, a default and what it blocks |
|
|
14
|
+
|
|
15
|
+
Each script wraps one `catalyst-skills write` verb, which posts to the route the contract names, as the tenant's app actor, with the account key this machine connected with. No script composes a URL, and none needs a Linear credential of its own.
|
|
16
|
+
|
|
17
|
+
## App actor versus personal identity
|
|
18
|
+
|
|
19
|
+
Every write goes out as the tenant's Catalyst app actor by default. That identity is what the cloud's own comment-wake trigger recognises as "not a human", so an app-actor comment never wakes an agent to reply to it, never needs the bookkeeping marker to be safe, and reads to the human as Catalyst speaking. `--as-user` switches a comment or a new ticket to the personal identity behind the key; use it only when the human asked for that attribution, and then mark any machine record with `--bookkeeping`, because on that path the record is indistinguishable from the human typing it.
|
|
20
|
+
|
|
21
|
+
Reply where the message arrived: a comment inside a thread is answered with `--parent <commentId>`, in that thread, and never as a new ticket. Never post as the human; you speak as Catalyst, or as yourself by role.
|
|
22
|
+
|
|
23
|
+
## The daily write budget
|
|
24
|
+
|
|
25
|
+
Every write route spends one unit of a per-host daily budget the contract publishes as `thresholds.hostDailyWriteBudget`; a label call spends one unit whatever the label count; reads spend none. When the budget is spent, the CLI refuses with exit 2 and a line that names the budget and the retry time. Do not retry in a loop; report it, and batch what you can into fewer writes (one comment, not five).
|
|
26
|
+
|
|
27
|
+
## State moves are by slot, never by name
|
|
28
|
+
|
|
29
|
+
`--slot <slot>` names one of the eleven slots; the CLI resolves it to this team's live state id from the contract. It refuses when the slot is unmapped for the team, and when the mapped state no longer exists in Linear (the map needs fixing in tenant settings first). Moving to `dispatch` is how work is dispatched; nothing is offered from any other column. Backlog is not a slot: `--state-type backlog` resolves the team's first backlog-type state from its live workflow states, and moving a card there parks it and stops remediation rounds. A move never fabricates a state id, and a move by stage name is not offered because names are display fields that survive re-imports while ids do not.
|
|
30
|
+
|
|
31
|
+
## Labels are by contract id
|
|
32
|
+
|
|
33
|
+
A name the contract lists for the team (the ask marker, the hold label, the release label) resolves to that team's preferred id; when the workspace has no such label the CLI refuses rather than inventing one. Any other value is sent as a label id as given, so a label outside Catalyst's own set needs its id, which the ticket record's `labels[]` shows.
|
|
34
|
+
|
|
35
|
+
Two labels are a human's, not yours: the release label (which frees a ticket the shape detector held) is applied by a human; the hold labels on a pull request are `catalyst-github`'s subject.
|
|
36
|
+
|
|
37
|
+
## A new ticket takes the team key
|
|
38
|
+
|
|
39
|
+
`create-ticket.mjs --team <key>` names the team by the prefix its identifiers carry; the CLI resolves the team id from the contract and the cloud fences the write to your tenant on the team, not only on the credential. Cite the identifier only after the script prints it; a guessed number is usually a real, unrelated ticket. Do not file a question for a human this way: a ticket whose text reads as a decision request is held out of dispatch by shape until someone releases it, and it would never reach the human's inbox with what it blocks.
|
|
40
|
+
|
|
41
|
+
## What is never offered
|
|
42
|
+
|
|
43
|
+
Delegation (assigning a ticket to an agent) is an operator action, not a tenant write. Answering an ask on the human's behalf is not a write you make; the human answers, and `what-needs-me` records the acceptance. Editing another actor's comment is not available.
|