@catalyst-cloud/cli 0.14.6 → 0.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
@@ -2,18 +2,19 @@
2
2
  // is-it-mergeable.mjs — the three legs of merge evidence for one PR (checks, reviewer signal,
3
3
  // unresolved threads) judged under the policy the tenant contract resolves for its repository, plus
4
4
  // the prerequisites a queue applies before any leg counts (open, not a draft, no conflict). Every
5
- // input comes from `catalyst query`, `contract` and, when fresh, `replica sql`.
5
+ // input comes from cloud-backed `catalyst query` and `contract`; --local-threads opts into `replica sql`.
6
6
  // Exit 0 no leg is red, 1 a leg or prerequisite is red, 2 this machine is not connected.
7
7
  import { parseJson, runCli, runCliOrExit } from "./lib/cli.mjs";
8
8
  import { resolvePull, truthy } from "./lib/pull.mjs";
9
9
 
10
- const HELP = `Usage: node scripts/is-it-mergeable.mjs <ticket | pr-node-id> [--json]
10
+ const HELP = `Usage: node scripts/is-it-mergeable.mjs <ticket | pr-node-id> [--json] [--local-threads]
11
11
 
12
12
  Judges one pull request against the merge policy the tenant contract resolves for its repository:
13
13
  checks every gating check at the head completed without failing (pending or absent is not green)
14
14
  reviewer the reviewer signal the policy requires; the clean-pass reaction itself is not mirrored,
15
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
16
+ threads inconclusive when the cloud PR detail carries no thread count;
17
+ --local-threads opts into reading a local replica caught up to a probed cloud head
17
18
 
18
19
  A red prerequisite (closed, draft, merge conflict) is also exit 1. Inconclusive legs never fail the
19
20
  exit code; the cloud's own evaluator, which holds the reaction and the thread ancestry, is authoritative.
@@ -32,14 +33,20 @@ if (!target) {
32
33
  }
33
34
 
34
35
  const { ticket, detail } = resolvePull(target);
35
- const merge = parseJson(runCliOrExit(["contract", "--path", "merge", "--json"]).stdout);
36
+ const merge = parseJson(
37
+ runCliOrExit(["contract", "--path", "merge", "--json"]).stdout,
38
+ );
36
39
  if (!merge || !Array.isArray(merge.policies)) {
37
- console.error("the contract's merge block did not parse; run: catalyst contract --refresh");
40
+ console.error(
41
+ "the contract's merge block did not parse; run: catalyst contract --refresh",
42
+ );
38
43
  process.exit(1);
39
44
  }
40
45
 
41
- const repoId = String(detail.repo_id ?? "");
42
- const repoRow = (merge.repositories ?? []).find((r) => `${r.owner}/${r.name}`.toLowerCase() === repoId.toLowerCase());
46
+ const repoId = typeof detail.repo_id === "string" ? detail.repo_id : "";
47
+ const repoRow = (merge.repositories ?? []).find(
48
+ (r) => `${r.owner}/${r.name}`.toLowerCase() === repoId.toLowerCase(),
49
+ );
43
50
  const policy = repoRow?.policy ?? merge.defaultPolicy;
44
51
  const policySource = repoRow ? repoRow.policySource : "default";
45
52
  const requiresReviewer = policy !== "checks-and-threads";
@@ -50,75 +57,233 @@ const add = (leg, state, line) => legs.push({ leg, state, line });
50
57
 
51
58
  // ── prerequisites the queue applies before any leg counts ──────────────────────────────────────
52
59
  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`);
60
+ add(
61
+ "state",
62
+ "pass",
63
+ `already merged${detail.merged_at ? ` at ${new Date(Number(detail.merged_at)).toISOString()}` : ""}; nothing left to judge`,
64
+ );
54
65
  } else {
55
66
  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");
67
+ if (state && state !== "open")
68
+ add("state", "fail", `the PR is ${state}, not open`);
69
+ else if (truthy(detail.draft))
70
+ add(
71
+ "state",
72
+ "fail",
73
+ "the PR is still a draft; the pr phase marks it ready for review, a queue never takes a draft",
74
+ );
58
75
  else add("state", "pass", "open and ready for review");
59
76
  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`);
77
+ if (ms === "dirty" || detail.mergeable === false || detail.mergeable === 0)
78
+ add(
79
+ "conflict",
80
+ "fail",
81
+ `GitHub reports a merge conflict with the base branch (mergeable_state=${ms || "unknown"}); a remediate round rebases it`,
82
+ );
61
83
  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");
84
+ else
85
+ add(
86
+ "conflict",
87
+ "inconclusive",
88
+ "GitHub has not reported a mergeable state for this head yet",
89
+ );
63
90
  }
64
91
 
65
92
  // ── leg 1: checks ───────────────────────────────────────────────────────────────────────────────
66
93
  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 ?? "")));
94
+ const required = Array.isArray(merge.cloudRemediateRequiredChecks)
95
+ ? merge.cloudRemediateRequiredChecks
96
+ : [];
97
+ const notQueue = checks.filter(
98
+ (c) => !/mergify|^queue:|^summary$/i.test(String(c.name ?? "")),
99
+ );
69
100
  const gating = notQueue.filter((c) => required.includes(String(c.name)));
70
101
  const judged = gating.length > 0 ? gating : notQueue;
71
102
  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}`);
103
+ const red = judged.filter(
104
+ (c) =>
105
+ String(c.status).toLowerCase() === "completed" &&
106
+ !ok.has(String(c.conclusion).toLowerCase()),
107
+ );
108
+ const pending = judged.filter(
109
+ (c) => String(c.status).toLowerCase() !== "completed",
110
+ );
111
+ const statuses = Array.isArray(detail.commit_statuses)
112
+ ? detail.commit_statuses
113
+ : [];
114
+ const redStatuses = statuses.filter((s) =>
115
+ ["failure", "error"].includes(String(s.state).toLowerCase()),
116
+ );
117
+ const scope =
118
+ gating.length > 0
119
+ ? `the ${gating.length} required check(s) the contract names`
120
+ : `every mirrored check (none of the contract's required names is present at this head)`;
121
+ if (judged.length === 0 && redStatuses.length === 0)
122
+ add(
123
+ "checks",
124
+ "inconclusive",
125
+ "no check has reported at this head; an unrun set is unknown, never green",
126
+ );
127
+ else if (red.length > 0 || redStatuses.length > 0)
128
+ add(
129
+ "checks",
130
+ "fail",
131
+ `red: ${[...red.map((c) => `${c.name} (${c.conclusion})`), ...redStatuses.map((s) => `${s.context} (${s.state})`)].join(", ")}`,
132
+ );
133
+ else if (pending.length > 0)
134
+ add(
135
+ "checks",
136
+ "inconclusive",
137
+ `still running: ${pending.map((c) => c.name).join(", ")} — judged over ${scope}`,
138
+ );
80
139
  else add("checks", "pass", `green over ${scope}`);
81
140
 
82
141
  // ── leg 2: the reviewer signal ─────────────────────────────────────────────────────────────────
83
142
  const reviews = Array.isArray(detail.reviews) ? detail.reviews : [];
84
143
  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`);
144
+ const byReviewer = reviews.filter(
145
+ (r) =>
146
+ String(r.reviewer_name ?? "") === login ||
147
+ String(r.reviewer_id ?? "") === `github:${login}`,
148
+ );
149
+ if (!requiresReviewer)
150
+ add("reviewer", "pass", `policy ${policy} needs no reviewer signal`);
87
151
  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`);
152
+ const states = [
153
+ ...new Set(byReviewer.map((r) => String(r.state ?? "?"))),
154
+ ].join(", ");
155
+ add(
156
+ "reviewer",
157
+ "inconclusive",
158
+ `${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"}`,
159
+ );
160
+ } else
161
+ add(
162
+ "reviewer",
163
+ "inconclusive",
164
+ `no review object from ${login} is mirrored; a clean pass is a reaction, which this read cannot see`,
165
+ );
91
166
 
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)`);
167
+ // The default cloud read requires no local writer or database. Thread ancestry remains the
168
+ // cloud evaluator's responsibility; absence of thread evidence never becomes a green leg.
169
+ const localThreads = args.includes("--local-threads");
170
+ const replicaResult = localThreads
171
+ ? runCli(["replica", "status", "--probe", "--json"])
172
+ : null;
173
+ const replica =
174
+ replicaResult?.code === 0 ? parseJson(replicaResult.stdout) : null;
175
+ if (
176
+ localThreads &&
177
+ replica?.verdict === "fresh" &&
178
+ Number.isSafeInteger(replica.cursor) &&
179
+ replica.cursor >= 0 &&
180
+ replica.cursor === replica.head
181
+ ) {
182
+ const num = detail.number;
183
+ if (
184
+ typeof num !== "number" ||
185
+ !Number.isSafeInteger(num) ||
186
+ num <= 0 ||
187
+ !repoId ||
188
+ repoId !== repoId.trim() ||
189
+ repoId.length > 512 ||
190
+ /[\u0000-\u001f\u007f-\u009f]/.test(repoId)
191
+ ) {
192
+ add(
193
+ "threads",
194
+ "inconclusive",
195
+ "the PR identity is incomplete; local thread evidence could not be read",
196
+ );
197
+ } else {
198
+ const esc = repoId.replace(/'/g, "''");
199
+ const sql = `select resolved, count(*) as n from pr_review_threads where repo_id = '${esc}' and pr_number = ${num} group by resolved`;
200
+ const result = runCli(["replica", "sql", sql, "--json"]);
201
+ const rows = parseJson(result.stdout);
202
+ if (
203
+ result.code !== 0 ||
204
+ !Array.isArray(rows) ||
205
+ rows.some(
206
+ (row) =>
207
+ !row ||
208
+ ![0, 1, null].includes(row.resolved) ||
209
+ typeof row.n !== "number" ||
210
+ !Number.isSafeInteger(row.n) ||
211
+ row.n < 0,
212
+ )
213
+ ) {
214
+ add(
215
+ "threads",
216
+ "inconclusive",
217
+ "the optional local thread read could not be verified; the cloud evaluator decides",
218
+ );
219
+ } else {
220
+ const count = (want) =>
221
+ rows
222
+ .filter((r) =>
223
+ want === null
224
+ ? r.resolved === null || r.resolved === undefined
225
+ : r.resolved === want,
226
+ )
227
+ .reduce((a, r) => a + Number(r.n ?? 0), 0);
228
+ const unresolved = count(0);
229
+ const unknown = count(null);
230
+ if (unresolved > 0)
231
+ add("threads", "fail", `${unresolved} unresolved review thread(s)`);
232
+ else if (unknown > 0)
233
+ add(
234
+ "threads",
235
+ "inconclusive",
236
+ `${unknown} thread(s) with no resolved flag mirrored`,
237
+ );
238
+ else
239
+ add(
240
+ "threads",
241
+ "pass",
242
+ `${count(1)} thread(s), all resolved (ancestry against force-pushes is judged by the cloud, not here)`,
243
+ );
244
+ }
245
+ }
105
246
  } else {
106
- add("threads", "inconclusive", `the PR detail carries no thread count and the replica is ${replica?.verdict ?? "unavailable"}; start it (catalyst replica start --detach) for a local read`);
247
+ add(
248
+ "threads",
249
+ "inconclusive",
250
+ localThreads
251
+ ? "the optional local replica has not proven that it is caught up to the cloud head; the cloud evaluator decides"
252
+ : "the cloud PR detail carries no thread count; the cloud evaluator decides",
253
+ );
107
254
  }
108
255
 
109
256
  const failed = legs.filter((l) => l.state === "fail");
110
257
  const open = legs.filter((l) => l.state === "inconclusive");
111
258
  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`;
259
+ if (failed.length > 0)
260
+ verdict = `NOT MERGEABLE: ${failed.map((l) => l.leg).join(", ")}`;
261
+ else if (open.length > 0)
262
+ verdict = `NOT PROVEN MERGEABLE from this read (${open.map((l) => l.leg).join(", ")} inconclusive); the cloud's evaluator decides`;
114
263
  else verdict = "MERGEABLE as far as this read can see";
115
264
 
116
265
  const header = `${repoId}#${detail.number ?? "?"}${ticket ? ` (${ticket})` : ""} head ${String(detail.head_sha ?? "").slice(0, 12)} — policy ${policy} (${policySource})`;
117
266
  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 }));
267
+ console.log(
268
+ JSON.stringify({
269
+ pr: {
270
+ repo_id: repoId,
271
+ number: detail.number,
272
+ node_id: detail.node_id,
273
+ ticket,
274
+ head_sha: detail.head_sha,
275
+ },
276
+ policy: { name: policy, source: policySource },
277
+ legs,
278
+ verdict,
279
+ }),
280
+ );
119
281
  } else {
120
282
  console.log(header);
121
- for (const l of legs) console.log(`${l.state === "pass" ? "ok " : l.state === "fail" ? "FAIL" : "? "} ${l.leg}: ${l.line}`);
283
+ for (const l of legs)
284
+ console.log(
285
+ `${l.state === "pass" ? "ok " : l.state === "fail" ? "FAIL" : "? "} ${l.leg}: ${l.line}`,
286
+ );
122
287
  console.log(verdict);
123
288
  }
124
289
  process.exit(failed.length > 0 ? 1 : 0);
@@ -12,7 +12,7 @@ const HELP = `Usage: node scripts/read-pr.mjs <ticket | pr-node-id> [--all] [--j
12
12
  --json print the raw detail document instead of the summary
13
13
 
14
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).`;
15
+ The first stderr line names the source the CLI read from (the API by default, or an explicitly selected replica).`;
16
16
 
17
17
  const args = process.argv.slice(2);
18
18
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
@@ -52,8 +52,15 @@ if (all) {
52
52
  process.exit(1);
53
53
  }
54
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, "")}]`);
55
+ const flags = [
56
+ truthy(p.draft) ? "draft" : null,
57
+ truthy(p.merged) ? "merged" : null,
58
+ ]
59
+ .filter(Boolean)
60
+ .join(" ");
61
+ console.log(
62
+ `${fmt(p.repo_id)}#${fmt(p.number)} ${fmt(p.state)}${flags ? ` ${flags}` : ""} ${fmt(p.title, "")} [${fmt(p.node_id, "")}]`,
63
+ );
57
64
  }
58
65
  process.exit(0);
59
66
  }
@@ -66,23 +73,44 @@ if (json) {
66
73
 
67
74
  const checks = Array.isArray(detail.checks) ? detail.checks : [];
68
75
  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(", ");
76
+ const statuses = Array.isArray(detail.commit_statuses)
77
+ ? detail.commit_statuses
78
+ : [];
79
+ const state = [
80
+ fmt(detail.state),
81
+ truthy(detail.draft) ? "draft" : null,
82
+ truthy(detail.merged) ? `merged ${when(detail.merged_at)}` : null,
83
+ ]
84
+ .filter(Boolean)
85
+ .join(", ");
71
86
 
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"}`);
87
+ console.log(
88
+ `${fmt(detail.repo_id)}#${fmt(detail.number)} ${fmt(detail.title, "(no title yet)")}`,
89
+ );
90
+ console.log(
91
+ `state: ${state} by ${fmt(detail.author_login)} opened ${when(detail.created_at)} updated ${when(detail.updated_at)}`,
92
+ );
93
+ console.log(
94
+ `branch: ${fmt(detail.head_ref)} → ${fmt(detail.base_ref)} head ${fmt(detail.head_sha).slice(0, 12)}`,
95
+ );
96
+ console.log(
97
+ `ticket: ${fmt(ticket ?? detail.linear_issue_identifier, "none named")}${detail.linked_issue_state ? ` (stage: ${detail.linked_issue_state})` : ""}`,
98
+ );
99
+ console.log(
100
+ `github says: mergeable=${fmt(detail.mergeable, "unknown")} state=${fmt(detail.mergeable_state, "unknown")} auto-merge=${truthy(detail.auto_merge) ? "on" : "off"}`,
101
+ );
77
102
  if (detail.blocked_on_ask && typeof detail.blocked_on_ask === "object") {
78
103
  const b = detail.blocked_on_ask;
79
- console.log(`blocked on an ask: ${fmt(b.identifier ?? b.id, "")} ${fmt(b.title, "")}`.trim());
104
+ console.log(
105
+ `blocked on an ask: ${fmt(b.identifier ?? b.id, "")} ${fmt(b.title, "")}`.trim(),
106
+ );
80
107
  }
81
108
  console.log(`node id: ${fmt(detail.node_id)}`);
82
109
 
83
110
  console.log(`checks (${checks.length}):`);
84
111
  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)}`);
112
+ for (const c of checks)
113
+ console.log(` ${fmt(c.status)}/${fmt(c.conclusion, "-")} ${fmt(c.name)}`);
86
114
 
87
115
  if (statuses.length > 0) {
88
116
  console.log(`commit statuses (${statuses.length}):`);
@@ -90,8 +118,16 @@ if (statuses.length > 0) {
90
118
  }
91
119
 
92
120
  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)}`);
121
+ if (reviews.length === 0)
122
+ console.log(
123
+ " none mirrored; a reviewer's clean pass is a reaction, which this read does not carry",
124
+ );
125
+ for (const r of reviews)
126
+ console.log(
127
+ ` ${fmt(r.state)} ${fmt(r.reviewer_name, fmt(r.reviewer_id))} ${when(r.submitted_at)}`,
128
+ );
95
129
 
96
- console.log("not mirrored here: PR labels (holds, queue attestation) and the reviewer's reaction — see references/what-a-pr-accumulates.md");
130
+ console.log(
131
+ "not mirrored here: PR labels (holds, queue attestation) and the reviewer's reaction — see references/what-a-pr-accumulates.md",
132
+ );
97
133
  process.exit(0);
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: catalyst-linear
3
3
  description: >-
4
- Catalyst's view of Linear on the customer's own cloud account. 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 account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's 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).
4
+ Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's 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
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.6 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.8 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
11
- You read tickets and write to them in the person's cloud account, as Catalyst. Reads come from the replica when it is fresh, else the API, and name their source. Writes go out as the app actor, with routes, stage ids, label ids and the bookkeeping marker resolved from the contract by the `catalyst` CLI.
11
+ You read tickets and write to them in the person's cloud account, as Catalyst. Reads come from the cloud API by default and name their source. A person may explicitly choose a local replica read; setup does not require a replica. Writes go out as the app actor, with routes, stage ids, label ids and the bookkeeping marker resolved from the contract by the `catalyst` CLI.
12
12
 
13
13
  ## Run first
14
14
 
@@ -30,6 +30,8 @@ Exit codes: 0 done, 1 not found or a usage error, 2 this machine is not connecte
30
30
  | "what did Catalyst write on this ticket?", a comment shape, a label, the marker, the 👀 | `references/what-a-ticket-accumulates.md` |
31
31
  | before any read you will report on; "is this current?"; citing; searching | `references/reading-a-ticket.md` |
32
32
  | before any comment, move, label or new ticket; identity, budget, slots, ids | `references/writing-to-linear.md` |
33
+ | which command answers a ticket question from the cloud: stage, last phase, PR, what changed | `references/reading-from-the-cloud.md` |
34
+ | local SQL, only when `catalyst replica status --json` reports `configured: true` | `references/local-replica.md` |
33
35
 
34
36
  ## Rules
35
37
 
@@ -0,0 +1,91 @@
1
+ # Reading a ticket from the local replica (opt-in)
2
+
3
+ Load this only when `catalyst replica status --json` reports `configured: true`. On any other machine, read from the cloud (`references/reading-from-the-cloud.md`) and do not mention the replica.
4
+
5
+ The replica is an on-machine SQLite copy of the account. It exists only when the person opted in to local sync. It is for ad hoc SQL and many repeated reads. It is never required, and the cloud read stays the default.
6
+
7
+ ## Check it is caught up first
8
+
9
+ Run `catalyst replica status --probe --json` before every local read.
10
+
11
+ - Read locally only when `verdict` is `fresh` and `cursor` equals `head`. That proves the copy is caught up with the cloud at that moment.
12
+ - `fresh` without the probe proves a live writer, not a caught-up copy.
13
+ - `stale` (exit 1) or `absent` (exit 3) with `configured: true` means the person opted in and the writer is down. Answer from the cloud and say the local copy is behind. `catalyst replica start --detach` restarts it, but only start it when the person asks.
14
+
15
+ Quote the cursor with any local answer, for example "from the local replica at cursor 4810".
16
+
17
+ ## Two ways to read it
18
+
19
+ - `catalyst query issue <KEY-123> --source replica` reads the ticket from the local copy. A replica built by an older SDK can lack fields the cloud read has, such as `linked_pulls`. `--source replica` on an absent replica is refused, never silently answered from the API.
20
+ - `catalyst replica sql "<one SELECT>" --json` runs one read-only `SELECT` and prints rows. Anything other than a single `SELECT` is refused.
21
+
22
+ `catalyst replica schema` prints every table with its columns, and `catalyst replica schema <table>` prints one. Read the columns before writing SQL against a table this page does not cover.
23
+
24
+ ## What the tables hold
25
+
26
+ Timestamps are milliseconds since the epoch. A row with `removed_at` set was deleted upstream, so filter `removed_at is null` for current rows.
27
+
28
+ | table | one row is | join on |
29
+ | -- | -- | -- |
30
+ | `issues` | a ticket: `identifier`, `title`, `state`, `state_type`, `assignee`, `delegate_name`, `priority`, `team_key`, `project_id`, `cycle_id`, `parent_identifier`, `updated_at` | `id` |
31
+ | `comments` | a comment: `body`, `author_name`, `is_bot`, `parent_id`, `created_at` | `issue_id` = `issues.id` |
32
+ | `issue_history` | one change to a ticket: `from_state` / `to_state` and the other `from_*` / `to_*` pairs, `created_at` | `issue_id` = `issues.id` |
33
+ | `relations` | a link between tickets: `type`, `issue_identifier`, `related_identifier` | identifiers |
34
+ | `labels`, `issue_labels` | a label, and which ticket carries it | `issue_labels.label_id` = `labels.id` |
35
+ | `agent_sessions` | an agent session on a ticket: `status`, `created_at` | `issue_id` = `issues.id` |
36
+ | `pull_requests` | a pull request that names a ticket in `linear_issue_identifier` | `linear_issue_identifier` = `issues.identifier` |
37
+
38
+ ## Recipes
39
+
40
+ Replace `ENG-123` and `ENG` with the real identifier and team key.
41
+
42
+ One ticket's current fields:
43
+
44
+ ```sql
45
+ select identifier, title, state, assignee, delegate_name, priority, updated_at
46
+ from issues where identifier = 'ENG-123' and removed_at is null
47
+ ```
48
+
49
+ The newest comments on a ticket, Catalyst's own first:
50
+
51
+ ```sql
52
+ select c.created_at, c.author_name, c.is_bot, substr(c.body, 1, 200) as body
53
+ from comments c join issues i on i.id = c.issue_id
54
+ where i.identifier = 'ENG-123' and c.removed_at is null
55
+ order by c.is_bot desc, c.created_at desc limit 20
56
+ ```
57
+
58
+ Every state change on a ticket, oldest first:
59
+
60
+ ```sql
61
+ select h.created_at, h.from_state, h.to_state
62
+ from issue_history h join issues i on i.id = h.issue_id
63
+ where i.identifier = 'ENG-123' and h.to_state is not null
64
+ order by h.created_at
65
+ ```
66
+
67
+ How many tickets sit in each state on one team:
68
+
69
+ ```sql
70
+ select state, count(*) as n from issues
71
+ where team_key = 'ENG' and removed_at is null
72
+ group by state order by n desc
73
+ ```
74
+
75
+ What a ticket blocks and what blocks it:
76
+
77
+ ```sql
78
+ select type, issue_identifier, related_identifier from relations
79
+ where issue_identifier = 'ENG-123' or related_identifier = 'ENG-123'
80
+ ```
81
+
82
+ Pull requests that name a ticket:
83
+
84
+ ```sql
85
+ select repo_id, number, state, draft, merged, head_sha
86
+ from pull_requests where linear_issue_identifier = 'ENG-123'
87
+ ```
88
+
89
+ ## What the replica does not answer
90
+
91
+ Phase attempts, remediate rounds, park state and the live lease are not in the replica. `catalyst history <KEY-123>` reads them from the cloud. So do eligibility (`catalyst explain <KEY-123>`) and search (`node scripts/search.mjs`).
@@ -4,13 +4,13 @@
4
4
 
5
5
  Every `query` verb, and therefore `node scripts/read-ticket.mjs` and `node scripts/search.mjs`, prints one stderr line before the answer:
6
6
 
7
- - `source: replica (cursor N)`: the local replica was fresh, so the answer is local and cheap.
8
- - `source: api (replica stale|absent|not configured)`: the answer came from the origin-fresh cloud API; a stale replica always falls back.
7
+ - `source: replica (--source replica)`: the person explicitly selected the optional local replica.
8
+ - `source: api (cloud reads by default)`: the answer came from the cloud API; no local replica is needed.
9
9
  - `source: api (<verb> is api-only)`: search, cycles, pull detail and change feeds never come from the replica.
10
10
 
11
11
  Quote the source when a fact's freshness matters ("as of the replica at cursor N", "from the API just now").
12
12
 
13
- `--source replica` or `--source api` forces one. Forcing the replica when it is absent or stale is refused.
13
+ `--source replica` or `--source api` forces one. Forcing the replica when it is absent is refused. No offline-browsing promise is made.
14
14
 
15
15
  ## Then the row
16
16
 
@@ -0,0 +1,38 @@
1
+ # Reading ticket facts from the cloud
2
+
3
+ Cloud reads are the default on every machine. They need no local writer and no local database. Every command below reads the cloud through the `catalyst` CLI.
4
+
5
+ | question | command |
6
+ | -- | -- |
7
+ | What stage is the ticket in, who has it, which PRs name it? | `catalyst query issue <KEY-123>`, or `node scripts/read-ticket.mjs <KEY-123>` |
8
+ | What did Catalyst and people write on it? | `node scripts/read-ticket.mjs <KEY-123> --comments`; comments and activity come inline with the ticket |
9
+ | What was the last phase, and how did it end? | `catalyst history <KEY-123>` |
10
+ | How many remediate rounds against the cap? Is it parked, and what releases it? | `catalyst history <KEY-123>` |
11
+ | Why is it not running, or what runs next? | `catalyst explain <KEY-123>` |
12
+ | What is the PR's state, checks and reviews? | `catalyst query pulls --ticket <KEY-123>`, then `catalyst query pull <node-id>` |
13
+ | What changed across the account since a point? | `catalyst query changes --since head`, then the cursor it prints |
14
+ | Find a ticket by words | `node scripts/search.mjs <terms>` |
15
+ | What happened to it, event by event? | `catalyst events query --ticket <KEY-123>` |
16
+ | Wait until its phase finishes | `catalyst events wait-for --ticket <KEY-123> --type relay.phase.completed --after <head> --timeout 300` |
17
+
18
+ ## Reading the answer
19
+
20
+ The first stderr line names the source: `source: api (...)`. Quote it when freshness matters ("from the API just now").
21
+
22
+ The ticket read returns the whole record, so a question about one field still downloads comments and activity. That is fine for one ticket. For many tickets, use a list (`catalyst query issues --team <key>`) and read one record only where the list is not enough.
23
+
24
+ A smaller read is coming: the cloud will answer a ticket or its execution history with only the status fields. No CLI flag selects it yet, so read the full record and pick the fields you need.
25
+
26
+ ## Events
27
+
28
+ `catalyst events` reads the cloud by default and needs no local file.
29
+
30
+ - `catalyst events query --ticket <KEY-123>` prints the ticket's events, newest first. Add `--type`, `--limit` (up to 200), `--order asc` and `--before` or `--after` to page. An empty page says which sequences the cloud has indexed, so "no match" is not "never happened".
31
+ - To wait for something to happen, first run `catalyst events status --json` and keep its `head`. Then run `catalyst events wait-for --ticket <KEY-123> --type relay.phase.completed --after <head> --timeout 300`. Exit 0 prints the event, exit 1 is a timeout, and exit 4 means the cloud was unreachable.
32
+ - `catalyst events tail --ticket <KEY-123>` stays in the foreground and prints one JSON line per new event.
33
+
34
+ The ticket filter reads the event's ticket entity, `payload.ticket`, and the lease that caused it. `--from-cache` reads this machine's local event cache instead. It exists only where local sync is on, and it pays off when several agents on one machine share it.
35
+
36
+ ## The local replica
37
+
38
+ Only on a machine where `catalyst replica status --json` reports `configured: true`, `references/local-replica.md` adds local SQL. Everywhere else, these cloud reads are the whole answer.