@catalyst-cloud/cli 0.16.29 → 0.16.31

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 (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +7 -3
  3. package/bin/launch.js +28 -0
  4. package/dist/approved-release.js +210 -0
  5. package/dist/args.js +21 -0
  6. package/dist/capabilities.js +5 -0
  7. package/dist/cli-update-notice.js +7 -4
  8. package/dist/cli.js +19 -0
  9. package/dist/lease.js +302 -0
  10. package/dist/onboard-existing.js +12 -3
  11. package/dist/onboard-next.js +14 -0
  12. package/dist/onboard-projects.js +3 -3
  13. package/dist/onboard-repositories.js +10 -3
  14. package/dist/onboard-ui.js +5 -1
  15. package/dist/onboard.js +3 -0
  16. package/dist/published.js +15 -0
  17. package/dist/ready.js +11 -5
  18. package/dist/replica.js +43 -6
  19. package/dist/runner-auto-update.js +3 -1
  20. package/dist/runner-command.js +92 -11
  21. package/dist/runner-update.js +72 -16
  22. package/dist/setup-onboard-copy.js +41 -2
  23. package/dist/update-cli.js +234 -0
  24. package/dist/update-command.js +151 -0
  25. package/dist/update-inventory.js +96 -0
  26. package/dist/update-plugin.js +99 -0
  27. package/dist/update-policy.js +194 -0
  28. package/dist/update-replica.js +120 -0
  29. package/dist/update-sdk.js +262 -0
  30. package/dist/update-skills.js +64 -0
  31. package/dist/update.js +586 -0
  32. package/package.json +1 -1
  33. package/skills/catalyst-github/SKILL.md +1 -1
  34. package/skills/catalyst-linear/SKILL.md +1 -1
  35. package/skills/catalyst-onboard/SKILL.md +1 -1
  36. package/skills/catalyst-onboard/references/skill-sources.md +8 -0
  37. package/skills/run-this-project/SKILL.md +1 -1
  38. package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
  39. package/skills/unstick/SKILL.md +1 -1
  40. package/skills/what-needs-me/SKILL.md +1 -1
  41. package/skills/what-this-repo-needs/SKILL.md +1 -1
  42. package/skills/whats-happening/SKILL.md +1 -1
  43. package/skills/whats-happening/references/what-runs-next.md +1 -1
  44. package/skills/whats-happening/references/why-is-it-stuck.md +1 -1
package/dist/lease.js ADDED
@@ -0,0 +1,302 @@
1
+ // lease.ts — `catalyst lease` and `catalyst work available` (CTC-6400): a seat or one-shot session
2
+ // holds the ticket it works through a ticket lease, never a label, and sees what work is free.
3
+ //
4
+ // lease take <TICKET> --agent <name> [--ttl <minutes>] POST /api/v1/agent/ticket-lease/take
5
+ // lease renew <TICKET> --commit|--artifact|--finding|--pr <ref> [--note <text>]
6
+ // POST /api/v1/agent/ticket-lease/renew
7
+ // lease release <TICKET> POST /api/v1/agent/ticket-lease/release
8
+ // lease show <TICKET> GET /api/v1/agent/ticket-lease?ticket=
9
+ // work available [--team K] GET /api/v1/work-eligibility?team=
10
+ //
11
+ // ⛔ THE KEY IS THE CREDENTIAL. The cloud mints no lease credential (CTC-6398): it derives the holder
12
+ // `seat:<keyId>:<agent>` from the login on every call. What this machine keeps per lease is only what
13
+ // the next call must name: the seat name and the generation the take returned, in a 0600 file under
14
+ // the config directory. Renew and release read it, so a seat names the ticket and its progress.
15
+ //
16
+ // ⭐ A REFUSAL IS A 200. The store answers `outcome: "refused"` with a reason (`no_new_progress`,
17
+ // `ticket_lease_held`, `not_holder`, …); this module prints it and exits 1, so a script can stop on
18
+ // it. A malformed request (400), a seat that does not reach the ticket's team (403) and an exhausted
19
+ // budget (429) exit 1 the same way, with the server's reason.
20
+ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, chmodSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { flagString, positionals } from "./args.js";
23
+ import { CONFIG_MODE, configDirFor, requireConfig } from "./config.js";
24
+ import { loadContract } from "./contract.js";
25
+ import { UsageError } from "./errors.js";
26
+ import { apiClient } from "./transport.js";
27
+ const ROUTE = "/api/v1/agent/ticket-lease";
28
+ const TICKET = /^[A-Za-z][A-Za-z0-9]*-[1-9][0-9]*$/;
29
+ const AGENT = /^[a-z0-9-]{1,40}$/;
30
+ const ACCEPT = [400, 403, 404, 409, 429];
31
+ function object(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function text(value) {
35
+ return typeof value === "string" && value.trim().length > 0;
36
+ }
37
+ function leaseRow(value) {
38
+ return object(value) && typeof value.generation === "number" && typeof value.deadlineMs === "number";
39
+ }
40
+ function leaseDir(ctx) {
41
+ return join(configDirFor(ctx.home), "leases");
42
+ }
43
+ function leasePath(ctx, ticket, agent) {
44
+ return join(leaseDir(ctx), `${ticket}--${agent}.json`);
45
+ }
46
+ function saveLease(ctx, lease) {
47
+ mkdirSync(leaseDir(ctx), { recursive: true });
48
+ const path = leasePath(ctx, lease.ticket, lease.agent);
49
+ writeFileSync(path, JSON.stringify(lease) + "\n", { mode: CONFIG_MODE });
50
+ chmodSync(path, CONFIG_MODE);
51
+ }
52
+ function forgetLease(ctx, ticket, agent) {
53
+ rmSync(leasePath(ctx, ticket, agent), { force: true });
54
+ }
55
+ /** Refusals that mean this seat's lease is gone: it lapsed, or a later take replaced it. */
56
+ const GONE = new Set(["expired", "not_holder", "stale_generation"]);
57
+ /** Forget a stored lease the cloud says this seat no longer holds, so a later seat on the same
58
+ * ticket is not asked to name one with --agent. The exit stays 1: the renew or release did not happen. */
59
+ function forgetIfGone(ctx, ticket, agent, body) {
60
+ if (!object(body) || !object(body.refusal) || !GONE.has(String(body.refusal.reason)))
61
+ return;
62
+ forgetLease(ctx, ticket, agent);
63
+ ctx.stderr(`[catalyst] ${agent} no longer holds ${ticket}; forgot its stored lease`);
64
+ }
65
+ /** The leases this machine holds on a ticket, one per seat that took it. */
66
+ function storedLeases(ctx, ticket) {
67
+ let names;
68
+ try {
69
+ names = readdirSync(leaseDir(ctx));
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ const found = [];
75
+ for (const name of names.filter((n) => n.startsWith(`${ticket}--`) && n.endsWith(".json"))) {
76
+ try {
77
+ const value = JSON.parse(readFileSync(join(leaseDir(ctx), name), "utf8"));
78
+ if (object(value) && value.ticket === ticket && text(value.agent) && typeof value.generation === "number")
79
+ found.push({ ticket, agent: value.agent, generation: value.generation, holder: text(value.holder) ? value.holder : null });
80
+ }
81
+ catch {
82
+ // An unreadable file is not a lease this machine can name; --agent and --generation still can.
83
+ }
84
+ }
85
+ return found;
86
+ }
87
+ function parseTicket(raw, verb) {
88
+ if (!raw || !TICKET.test(raw))
89
+ throw new UsageError(`lease ${verb} needs one ticket such as ENG-123`);
90
+ return raw.toUpperCase();
91
+ }
92
+ function parseAgent(raw) {
93
+ if (raw === undefined)
94
+ return undefined;
95
+ if (!AGENT.test(raw))
96
+ throw new UsageError("--agent is the seat's name: 1 to 40 of a-z, 0-9 and -");
97
+ return raw;
98
+ }
99
+ /** The seat and generation a renew or release names: the flags, else the one lease this machine
100
+ * stored for the ticket. Two stored seats need --agent to choose. */
101
+ function heldLease(args, ctx, ticket) {
102
+ const agent = parseAgent(flagString(args, "agent"));
103
+ const rawGeneration = flagString(args, "generation");
104
+ let generation;
105
+ if (rawGeneration !== undefined) {
106
+ generation = Number(rawGeneration);
107
+ if (!Number.isSafeInteger(generation) || generation < 1)
108
+ throw new UsageError("--generation is the positive integer the take printed");
109
+ }
110
+ const stored = storedLeases(ctx, ticket).filter((s) => agent === undefined || s.agent === agent);
111
+ if (agent !== undefined && generation !== undefined)
112
+ return { agent, generation };
113
+ if (stored.length === 1)
114
+ return { agent: stored[0].agent, generation: generation ?? stored[0].generation };
115
+ if (stored.length > 1)
116
+ throw new UsageError(`this machine holds ${ticket} for ${stored.map((s) => s.agent).join(", ")}; name one with --agent`);
117
+ throw new UsageError(`this machine holds no lease on ${ticket}${agent ? ` for ${agent}` : ""}; take it first, or pass --agent and --generation`);
118
+ }
119
+ /** The one progress fact a renewal asserts, from exactly one of the four flags. */
120
+ function parseProgress(args) {
121
+ const given = ["commit", "artifact", "finding", "pr"]
122
+ .map((kind) => ({ kind, ref: flagString(args, kind) }))
123
+ .filter((p) => p.ref !== undefined);
124
+ if (given.length !== 1)
125
+ throw new UsageError("lease renew needs exactly one of --commit <sha>, --artifact <path or id>, --finding <id> or --pr <number>");
126
+ const { kind } = given[0];
127
+ const ref = given[0].ref.trim();
128
+ if (kind === "commit" && !/^[0-9a-f]{40}$/.test(ref))
129
+ throw new UsageError("--commit is the full 40-character lowercase sha");
130
+ if (kind === "pr" && !/^[1-9][0-9]{0,9}$/.test(ref))
131
+ throw new UsageError("--pr is the pull request's number");
132
+ if ((kind === "artifact" || kind === "finding") && (ref === "" || /\s/.test(ref)))
133
+ throw new UsageError(`--${kind} is one token with no spaces`);
134
+ const note = flagString(args, "note");
135
+ return { kind, ref, ...(note !== undefined && note.trim() !== "" ? { note } : {}) };
136
+ }
137
+ function parseTtlMs(args) {
138
+ const raw = flagString(args, "ttl");
139
+ if (raw === undefined)
140
+ return {};
141
+ const minutes = Number(raw);
142
+ if (!Number.isFinite(minutes) || minutes <= 0)
143
+ throw new UsageError("--ttl is a number of minutes (the cloud keeps it between 5 and 60)");
144
+ return { ttlMs: Math.round(minutes * 60_000) };
145
+ }
146
+ function when(ms) {
147
+ return new Date(ms).toISOString();
148
+ }
149
+ function detail(body) {
150
+ if (!object(body))
151
+ return "";
152
+ const parts = [body.reason, body.error, body.message].filter(text);
153
+ return parts.length ? `: ${[...new Set(parts)].join(": ")}` : "";
154
+ }
155
+ /** Print a refusal or an HTTP failure and return the exit code 1. */
156
+ function refuse(args, ctx, status, body, verb) {
157
+ if (args.json) {
158
+ ctx.stdout(JSON.stringify(object(body) ? { status, ...body } : { status }));
159
+ return 1;
160
+ }
161
+ if (status === 200 && object(body) && object(body.refusal)) {
162
+ const r = body.refusal;
163
+ const current = leaseRow(r.current) ? r.current : null;
164
+ const parts = [`refused: ${text(r.reason) ? r.reason : "unknown"}`];
165
+ if (current?.holder)
166
+ parts.push(`held by ${current.holder} until ${when(current.deadlineMs)}`);
167
+ if (typeof r.retryAtMs === "number")
168
+ parts.push(`retry at ${when(r.retryAtMs)}`);
169
+ ctx.stdout(parts.join("; "));
170
+ return 1;
171
+ }
172
+ const meaning = status === 404 ? `this Catalyst Cloud does not serve lease ${verb} yet (needs a newer cloud)`
173
+ : status === 403 ? "your login does not reach this ticket's team"
174
+ : status === 429 ? "your write budget is spent for now"
175
+ : `lease ${verb} failed`;
176
+ ctx.stdout(`refused (${status}): ${meaning}${detail(body)}`);
177
+ return 1;
178
+ }
179
+ function printLease(ctx, ticket, lease) {
180
+ ctx.stdout(`${ticket}: held by ${lease.holder ?? "nobody"} until ${when(lease.deadlineMs)}, generation ${lease.generation}`);
181
+ if (text(lease.lastProgress))
182
+ ctx.stdout(` last progress: ${lease.lastProgress}`);
183
+ }
184
+ export async function cmdLease(args, ctx) {
185
+ const [action, rawTicket, ...extra] = positionals(args);
186
+ if (action !== "take" && action !== "renew" && action !== "release" && action !== "show")
187
+ throw new UsageError("lease needs take, renew, release or show");
188
+ if (extra.length)
189
+ throw new UsageError(`lease ${action} takes one ticket`);
190
+ const ticket = parseTicket(rawTicket, action);
191
+ const client = apiClient(requireConfig(ctx), ctx);
192
+ if (action === "show") {
193
+ const res = await client.getJson(ROUTE, { query: { ticket }, accept: ACCEPT, parseAccepted: true });
194
+ if (res.status >= 400)
195
+ return refuse(args, ctx, res.status, res.body, action);
196
+ if (args.json) {
197
+ ctx.stdout(JSON.stringify(res.body));
198
+ return 0;
199
+ }
200
+ const lease = object(res.body) && leaseRow(res.body.lease) ? res.body.lease : null;
201
+ if (lease === null)
202
+ ctx.stdout(`${ticket}: no ticket lease`);
203
+ else if (lease.live === false) {
204
+ ctx.stdout(`${ticket}: free; last held by ${lease.lastHolder ?? lease.holder ?? "nobody"}, generation ${lease.generation}`);
205
+ if (text(lease.lastProgress))
206
+ ctx.stdout(` last progress: ${lease.lastProgress}`);
207
+ }
208
+ else
209
+ printLease(ctx, ticket, lease);
210
+ return 0;
211
+ }
212
+ if (action === "take") {
213
+ const agent = parseAgent(flagString(args, "agent"));
214
+ if (agent === undefined)
215
+ throw new UsageError("lease take needs --agent <seat name>");
216
+ const res = await client.postJson(`${ROUTE}/take`, { ticket, agent, ...parseTtlMs(args) }, { accept: ACCEPT });
217
+ if (res.status !== 200 || !object(res.body) || res.body.outcome !== "taken" || !leaseRow(res.body.lease))
218
+ return refuse(args, ctx, res.status, res.body, action);
219
+ const lease = res.body.lease;
220
+ saveLease(ctx, { ticket, agent, generation: lease.generation, holder: lease.holder });
221
+ if (args.json)
222
+ ctx.stdout(JSON.stringify(res.body));
223
+ else
224
+ printLease(ctx, ticket, lease);
225
+ return 0;
226
+ }
227
+ if (action === "renew") {
228
+ const progress = parseProgress(args);
229
+ const held = heldLease(args, ctx, ticket);
230
+ const res = await client.postJson(`${ROUTE}/renew`, { ticket, ...held, progress, ...parseTtlMs(args) }, { accept: ACCEPT });
231
+ if (res.status !== 200 || !object(res.body) || res.body.outcome !== "renewed" || !leaseRow(res.body.lease)) {
232
+ forgetIfGone(ctx, ticket, held.agent, res.body);
233
+ return refuse(args, ctx, res.status, res.body, action);
234
+ }
235
+ if (args.json)
236
+ ctx.stdout(JSON.stringify(res.body));
237
+ else
238
+ printLease(ctx, ticket, res.body.lease);
239
+ return 0;
240
+ }
241
+ const held = heldLease(args, ctx, ticket);
242
+ const res = await client.postJson(`${ROUTE}/release`, { ticket, ...held }, { accept: ACCEPT });
243
+ if (res.status !== 200 || !object(res.body) || res.body.outcome !== "released") {
244
+ forgetIfGone(ctx, ticket, held.agent, res.body);
245
+ return refuse(args, ctx, res.status, res.body, action);
246
+ }
247
+ forgetLease(ctx, ticket, held.agent);
248
+ if (args.json)
249
+ ctx.stdout(JSON.stringify(res.body));
250
+ else
251
+ ctx.stdout(`${ticket}: released by ${held.agent}`);
252
+ return 0;
253
+ }
254
+ /** The free tickets in one team's rows: offered, or offered with no capable runner (a seat can still
255
+ * take it), with no live holder named on them. */
256
+ function freeTickets(rows) {
257
+ return rows.filter((r) => text(r.ticket) && (r.status === "offered" || r.status === "capability_mismatch") && !text(r.holder));
258
+ }
259
+ /** `work available`: per team, the tickets nobody holds (with the last holder and progress when a
260
+ * lease lapsed) and the open pull requests no live lease covers, drafts included. */
261
+ export async function cmdWork(args, ctx) {
262
+ const [action, ...extra] = positionals(args);
263
+ if (action !== "available" || extra.length)
264
+ throw new UsageError("work needs available");
265
+ const cfg = requireConfig(ctx);
266
+ const client = apiClient(cfg, ctx);
267
+ const named = flagString(args, "team");
268
+ let teams;
269
+ if (named !== undefined)
270
+ teams = [named.toUpperCase()];
271
+ else {
272
+ const { doc } = await loadContract(ctx, cfg);
273
+ teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
274
+ if (teams.length === 0)
275
+ throw new UsageError("work available needs --team: the workspace contract names no team");
276
+ }
277
+ const results = {};
278
+ for (const team of teams) {
279
+ const res = await client.getJson("/api/v1/work-eligibility", { query: { team } });
280
+ const rows = Array.isArray(res.body.eligibility?.rows) ? res.body.eligibility.rows : [];
281
+ const pulls = Array.isArray(res.body.openPulls) ? res.body.openPulls : [];
282
+ results[team] = {
283
+ tickets: freeTickets(rows),
284
+ unownedPulls: pulls.filter((p) => object(p) && typeof p.number === "number" && p.owner === "unowned"),
285
+ };
286
+ }
287
+ if (args.json) {
288
+ ctx.stdout(JSON.stringify(named !== undefined ? results[teams[0]] : results));
289
+ return 0;
290
+ }
291
+ for (const team of teams) {
292
+ const { tickets, unownedPulls } = results[team];
293
+ ctx.stdout(`${team}: ${tickets.length} free ${tickets.length === 1 ? "ticket" : "tickets"}, ${unownedPulls.length} unowned open ${unownedPulls.length === 1 ? "pull request" : "pull requests"}`);
294
+ for (const row of tickets) {
295
+ const last = text(row.lastHolder) ? `; last held by ${row.lastHolder}${text(row.lastProgress) ? `, last progress ${row.lastProgress}` : ""}` : "";
296
+ ctx.stdout(` ${row.ticket}${last}`);
297
+ }
298
+ for (const pull of unownedPulls)
299
+ ctx.stdout(` #${pull.number}${pull.draft ? " (draft)" : ""}${text(pull.ticket) ? ` ${pull.ticket}` : ""}: unowned${text(pull.title) ? `, ${pull.title}` : ""}`);
300
+ }
301
+ return 0;
302
+ }
@@ -7,6 +7,14 @@ const waiting = (reason) => ({
7
7
  state: "waiting",
8
8
  reason,
9
9
  });
10
+ /**
11
+ * CTC-5932: at most 20 choices, so the advice stays one readable line, and the full count, so the
12
+ * advice never reports the cap as the workspace's total.
13
+ */
14
+ export const choiceEvidence = (values) => {
15
+ const unique = [...new Set(values)].sort();
16
+ return { choices: unique.slice(0, 20).join(","), choiceTotal: unique.length };
17
+ };
10
18
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
11
19
  ? value
12
20
  : null;
@@ -175,7 +183,8 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
175
183
  evidence: { teamKey: uncertain.key },
176
184
  }
177
185
  : undefined;
178
- const stopTeam = (reason) => uncertainResult() ?? waiting(reason);
186
+ const stopTeam = (reason, teams) => uncertainResult() ??
187
+ (teams?.length ? { ...waiting(reason), evidence: choiceEvidence(teams.map((team) => team.key)) } : waiting(reason));
179
188
  const requested = (teams, journal) => {
180
189
  const explicit = args.flags.team;
181
190
  if (typeof explicit === "string") {
@@ -210,7 +219,7 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
210
219
  return stopTeam("team_selection_unverified");
211
220
  return choose
212
221
  ? (uncertainResult("pending") ?? { state: "pending" })
213
- : stopTeam("team_choice_required");
222
+ : stopTeam("team_choice_required", list.teams);
214
223
  }
215
224
  const result = await observe(ctx, team.id, signal);
216
225
  if (!result || result.checks.get("team_visible") !== "pass")
@@ -257,7 +266,7 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
257
266
  if ("reason" in list)
258
267
  return stopTeam(list.reason);
259
268
  if (!choose)
260
- return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty");
269
+ return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty", list.teams);
261
270
  let offer = nameNewTeam && typeof args.flags.team !== "string"
262
271
  ? await teamCreateOffer(ctx, journal, signal)
263
272
  : undefined;
@@ -325,9 +325,23 @@ function firstTicketText(reason, key, stage) {
325
325
  }
326
326
  return undefined;
327
327
  }
328
+ /** CTC-5932: the choices a step recorded, for advice that names real flag values. */
329
+ function recordedChoices(step) {
330
+ const value = step.evidence?.choices;
331
+ return typeof value === "string" ? value.split(",").filter(Boolean) : [];
332
+ }
328
333
  /** One step's next action. `waitsFor` names the unfinished step a prerequisite wait is blocked on. */
329
334
  export function onboardReasonText(step, context = {}) {
330
335
  const reason = step.reason ?? "";
336
+ const choices = recordedChoices(step);
337
+ const recordedTotal = step.evidence?.choiceTotal;
338
+ const total = typeof recordedTotal === "number" && recordedTotal > choices.length ? recordedTotal : choices.length;
339
+ // The list is capped at 20, so a longer inventory says which part it shows.
340
+ const shown = total > choices.length ? `, the first ${choices.length} of them` : "";
341
+ if (reason === "team_choice_required" && choices.length)
342
+ return `This workspace has ${total} Linear ${total === 1 ? "team" : "teams"}${shown}: ${choices.join(", ")}. Add --team with one of them to the same command, for example --team ${choices[0]}.`;
343
+ if (reason === "repository_choice_required" && choices.length)
344
+ return `${total === 1 ? "One repository is" : `${total} repositories are`} available${shown}: ${choices.join(", ")}. Add --repo <owner/name> to the same command, for example --repo ${choices[0]}. Repeat --repo to choose more than one.`;
331
345
  if ([
332
346
  "workspace_approval_required",
333
347
  "personal_approval_required",
@@ -1,7 +1,7 @@
1
1
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
2
2
  import { loadConfig, normalizeBaseUrl } from "./config.js";
3
3
  import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
- import { existingRepositoryAdapter, } from "./onboard-repositories.js";
4
+ import { existingRepositoryAdapter, repositoryChoiceWait, } from "./onboard-repositories.js";
5
5
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
6
6
  ? value
7
7
  : null;
@@ -211,7 +211,7 @@ export function firstProjectAdapters(args, input = {}) {
211
211
  : waiting("repository_selection_unverified");
212
212
  return input.chooseFirst && list.rows.length
213
213
  ? { state: "pending" }
214
- : waiting("repository_choice_required");
214
+ : repositoryChoiceWait(list.rows);
215
215
  },
216
216
  act: async (ctx, journal, signal) => {
217
217
  const live = await existing.check(ctx, journal, signal);
@@ -221,7 +221,7 @@ export function firstProjectAdapters(args, input = {}) {
221
221
  if ("reason" in list)
222
222
  return waiting(list.reason);
223
223
  if (!input.chooseFirst || !list.rows.length)
224
- return waiting("repository_choice_required");
224
+ return repositoryChoiceWait(list.rows);
225
225
  if (list.truncated)
226
226
  input.message?.("GitHub returned part of its repository list. Choose a displayed repository, or stop and adjust the App access before resuming.");
227
227
  let remove = () => { };
@@ -1,7 +1,7 @@
1
1
  import { isRepositoryId } from "./repository-id.js";
2
2
  import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
3
3
  import { contractVersionInRange } from "./contract.js";
4
- import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
+ import { choiceEvidence, readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
5
5
  const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
6
6
  const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
7
7
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
@@ -12,6 +12,13 @@ const waiting = (reason) => ({
12
12
  state: "waiting",
13
13
  reason,
14
14
  });
15
+ /** CTC-5932: a choice no one was there to make, with the repositories it could have been. */
16
+ export const repositoryChoiceWait = (rows) => rows.length
17
+ ? {
18
+ ...waiting("repository_choice_required"),
19
+ evidence: choiceEvidence(rows.map((row) => `${row.owner}/${row.name}`)),
20
+ }
21
+ : waiting("repository_choice_required");
15
22
  const valid = (value) => {
16
23
  const row = object(value);
17
24
  return (!!row &&
@@ -381,7 +388,7 @@ export function existingRepositoryAdapter(args, choose) {
381
388
  ? waiting("repository_selection_unverified")
382
389
  : choose
383
390
  ? { state: "pending" }
384
- : waiting("repository_choice_required");
391
+ : repositoryChoiceWait(list.repositories);
385
392
  return {
386
393
  state: "done",
387
394
  evidence: {
@@ -400,7 +407,7 @@ export function existingRepositoryAdapter(args, choose) {
400
407
  if (!list.repositories.length)
401
408
  return waiting("repository_inventory_empty");
402
409
  if (!choose)
403
- return waiting("repository_choice_required");
410
+ return repositoryChoiceWait(list.repositories);
404
411
  const teamId = list.repositories[0].teamId;
405
412
  const offers = list.canRegister
406
413
  ? await offeredRepositories(ctx, journal, teamId, list.repositories, signal)
@@ -2,7 +2,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
2
2
  import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
3
3
  import { trackedPrompt } from "./setup-prompt-frame.js";
4
4
  import { createOnboardInterrupts } from "./onboard-interrupts.js";
5
- import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
5
+ import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, unattendedSkipLines, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
6
6
  import { createOnboardProgress, } from "./onboard-progress.js";
7
7
  import { CREATE_TEAM_CHOICE, suggestTeamKey, teamKeyProblem, teamNameProblem, } from "./onboard-team-create.js";
8
8
  import { FIRST_TICKET_SAMPLE, FIRST_TICKET_SKIP, } from "./onboard-first-ticket.js";
@@ -297,6 +297,10 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
297
297
  if (screen.paused)
298
298
  renderer.line("Your progress is saved.");
299
299
  renderer.tracker(setupTrackerRows(setupPartProgress(journal, planned.scope), "end"));
300
+ // CTC-5932: with no one to ask, setup says what it left out instead of letting it pass unsaid.
301
+ if (!interactive && !screen.paused)
302
+ for (const line of unattendedSkipLines(journal, planned))
303
+ renderer.line(line);
300
304
  if (screen.readinessCause)
301
305
  renderer.line(say(screen.readinessCause));
302
306
  if (mine.length) {
package/dist/onboard.js CHANGED
@@ -298,6 +298,9 @@ function normalizeStep(value, fallbackAt) {
298
298
  "passed",
299
299
  "team",
300
300
  "teamKey",
301
+ // CTC-5932: the comma-joined choices a run with no one to ask names in its flag advice.
302
+ "choices",
303
+ "choiceTotal",
301
304
  "selected",
302
305
  "hostName",
303
306
  "supervisorImage",
package/dist/published.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // reason instead of throwing: a customer who cannot reach npm must still get a verdict (CTC-2160).
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
+ import { readApprovalCache, supportedReleaseLine } from "./approved-release.js";
7
8
  import { LEGACY_PACKAGE_NAME, publishedCachePathFor } from "./config.js";
8
9
  /** CTC-3479 — asks about the forwarder package whichever package is installed. The release publishes
9
10
  * @catalyst-cloud/cli first and the forwarder last, at one version, so the forwarder's `latest`
@@ -77,3 +78,17 @@ export async function latestPublishedVersion(ctx, opts = {}) {
77
78
  writePublishedCache(ctx.home, { fetchedAt: ctx.now().toISOString(), latest });
78
79
  return { latest, reason: null, source: "network" };
79
80
  }
81
+ /**
82
+ * CTC-6708 — the CLI's "newest" answer is the approved release once this install has read one: every
83
+ * applying release check records it, the 15-minute schedule included. Reading it is local, so this
84
+ * adds no network call. An install that has never read an approved release, or read one on another
85
+ * train, falls back to the npm lookup above, and its answer stays labelled the latest published version.
86
+ */
87
+ export async function newestCliVersion(ctx, opts = {}) {
88
+ const approved = readApprovalCache(ctx.home)?.release;
89
+ const cli = approved?.members.cli;
90
+ // CTC-6741: a release on another train is never applied, so it is never the answer either.
91
+ if (approved && cli && approved.releaseLine === supportedReleaseLine())
92
+ return { latest: cli, reason: null, source: "approved", ...(approved.skills.cloud ? { skills: approved.skills.cloud.version } : {}) };
93
+ return latestPublishedVersion(ctx, opts);
94
+ }
package/dist/ready.js CHANGED
@@ -6,7 +6,7 @@ import { basename, delimiter, dirname, join } from "node:path";
6
6
  import { DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, upgradeCommand, } from "./config.js";
7
7
  import { contractVersionInRange, readContractCache } from "./contract.js";
8
8
  import { CliError } from "./errors.js";
9
- import { latestPublishedVersion } from "./published.js";
9
+ import { newestCliVersion } from "./published.js";
10
10
  import { replicaStatus, writerIsRunning, } from "./replica.js";
11
11
  import { loadHttpSdk } from "./sdk.js";
12
12
  import { FIX_COMMAND, detectRuntime, runtimeVerdict, supportedRangeText, } from "./runtime.js";
@@ -234,7 +234,7 @@ export async function readyReport(ctx, deps) {
234
234
  const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
235
235
  const offline = deps.offline === true || ctx.env.CATALYST_SKILLS_OFFLINE === "1";
236
236
  if (!offline) {
237
- const pub = await (deps.fetchLatestRelease ?? (() => latestPublishedVersion(ctx)))();
237
+ const pub = await (deps.fetchLatestRelease ?? (() => newestCliVersion(ctx)))();
238
238
  if (pub.latest === null) {
239
239
  checks.push({
240
240
  id: "cliRelease",
@@ -252,7 +252,10 @@ export async function readyReport(ctx, deps) {
252
252
  id: "cliRelease",
253
253
  ok: false,
254
254
  note: true,
255
- line: `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: ${upgradeCommand()}`,
255
+ // CTC-6708 — once this install has read an approved release, that is the answer.
256
+ line: pub.source === "approved"
257
+ ? `cliRelease: ${installedCli} installed is older than the approved ${pub.latest} — update: catalyst update`
258
+ : `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: ${upgradeCommand()}`,
256
259
  });
257
260
  }
258
261
  // clause 1 — the skill files on disk, read independently of whichever CLI is answering. Both
@@ -262,7 +265,8 @@ export async function readyReport(ctx, deps) {
262
265
  // older than any stamp, which is why the stamped half says "oldest STAMPED" and the unstamped
263
266
  // names are always printed rather than hidden behind whichever branch happened to win.
264
267
  const found = installedBundleVersion(skillsDir, deps.skillNames);
265
- const stampBehind = found.version !== null && semverOlder(found.version, pub.latest);
268
+ const skillsTarget = pub.source === "approved" ? (pub.skills ?? pub.latest) : pub.latest;
269
+ const stampBehind = found.version !== null && semverOlder(found.version, skillsTarget);
266
270
  const unstampedBehind = found.unstamped.length > 0 &&
267
271
  !semverOlder(pub.latest, FIRST_STAMPED_VERSION);
268
272
  if (stampBehind || unstampedBehind) {
@@ -277,7 +281,9 @@ export async function readyReport(ctx, deps) {
277
281
  id: "skillsRelease",
278
282
  ok: false,
279
283
  note: true,
280
- line: `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
284
+ line: pub.source === "approved"
285
+ ? `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the approved bundle is ${skillsTarget} — update: catalyst update`
286
+ : `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
281
287
  });
282
288
  }
283
289
  }
package/dist/replica.js CHANGED
@@ -3,10 +3,11 @@
3
3
  // `status` is the check every skill runs first and it needs no network and no SDK: the pidfile, the
4
4
  // writer-lock heartbeat, and the `sync_meta.cursor` row (read through node:sqlite read-only). Exit
5
5
  // 0 fresh, 1 present but stale, 2 not configured, 3 absent. `--probe` adds the one network call.
6
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { createRequire } from "node:module";
8
+ import { join } from "node:path";
8
9
  import { flagBool, flagInt, flagString, positionals } from "./args.js";
9
- import { apiBase, loadConfig, replicaDbPath, replicaOptedIn } from "./config.js";
10
+ import { apiBase, configDirFor, loadConfig, readManifest, replicaDbPath, replicaOptedIn } from "./config.js";
10
11
  import { detachSelf } from "./detach.js";
11
12
  import { CliError, UsageError } from "./errors.js";
12
13
  import { apiClient } from "./transport.js";
@@ -62,7 +63,8 @@ export function readWriterState(dbPath) {
62
63
  typeof rec.consecutiveFailures !== "number" ||
63
64
  !(rec.lastError === null || typeof rec.lastError === "string") ||
64
65
  !(rec.lastFailureAt === null || typeof rec.lastFailureAt === "number") ||
65
- !stoppedOk) {
66
+ !stoppedOk ||
67
+ !(rec.version === undefined || typeof rec.version === "string")) {
66
68
  return null;
67
69
  }
68
70
  return rec;
@@ -72,7 +74,7 @@ export function readWriterState(dbPath) {
72
74
  }
73
75
  }
74
76
  export function writeWriterState(dbPath, s, nowMs) {
75
- writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, updatedAt: nowMs }));
77
+ writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, version: readManifest().version, updatedAt: nowMs }));
76
78
  }
77
79
  /** Remove the writer state sidecar; a no-op (never throws) when it is already gone. */
78
80
  export function clearWriterState(dbPath) {
@@ -83,6 +85,25 @@ export function clearWriterState(dbPath) {
83
85
  // already gone
84
86
  }
85
87
  }
88
+ /** A restart a release check began and has not finished (update-replica.ts, CTC-6708). The owner's
89
+ * own `replica stop` clears it, so their stop is never undone. */
90
+ export function pendingRestartPathFor(home) {
91
+ return join(configDirFor(home), "replica-restart.json");
92
+ }
93
+ /** The restart a release check began for `dbPath` and has not confirmed, with the CLI version it
94
+ * expects the new writer to run when the record names one. */
95
+ export function pendingRestart(home, dbPath) {
96
+ try {
97
+ const raw = JSON.parse(readFileSync(pendingRestartPathFor(home), "utf8"));
98
+ if (typeof raw !== "object" || raw === null || raw.dbPath !== dbPath)
99
+ return null;
100
+ const target = raw.target;
101
+ return { dbPath, target: typeof target === "string" ? target : null };
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
86
107
  export function pidAlive(pid) {
87
108
  if (pid === null || !Number.isInteger(pid) || pid <= 0)
88
109
  return false;
@@ -348,7 +369,15 @@ async function cmdStatus(args, ctx) {
348
369
  status.lag = status.cursor === null ? head.body.cursor : head.body.cursor - status.cursor;
349
370
  }
350
371
  }
351
- ctx.stdout(args.json ? JSON.stringify(status) : statusLine(status));
372
+ const restart = status.dbPath !== null && !writerIsRunning(status) ? pendingRestart(ctx.home, status.dbPath) : null;
373
+ if (args.json)
374
+ ctx.stdout(JSON.stringify(restart ? { ...status, pendingRestart: restart } : status));
375
+ else {
376
+ ctx.stdout(statusLine(status));
377
+ // CTC-6743: a writer a release check stopped and restarted is down for that reason, not its owner's.
378
+ if (restart)
379
+ ctx.stdout(` a release check restarted it${restart.target ? ` on CLI ${restart.target}` : ""} and the new writer is not running; its synced data is kept at ${status.dbPath}, and the next release check starts it again`);
380
+ }
352
381
  return status.exitCode;
353
382
  }
354
383
  async function cmdStart(args, ctx, cfg, dbPath, deps) {
@@ -382,7 +411,9 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
382
411
  if (recordOwned)
383
412
  return;
384
413
  recordOwned = true;
385
- clearWriterState(dbPath);
414
+ // CTC-6743: a fresh, healthy record rather than none, so a release check can read the CLI
415
+ // version this writer runs without waiting for a failed pull to write one.
416
+ writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: 0, lastError: null, lastFailureAt: null, stopped: null }, ctx.now().getTime());
386
417
  };
387
418
  const resetFailures = () => {
388
419
  if (failures === 0)
@@ -413,6 +444,11 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
413
444
  log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
414
445
  onStatus: (s) => {
415
446
  ctx.stderr(`[replica] status=${s}`);
447
+ // CTC-6743: a status means start() already holds the writer lock, so this run owns the
448
+ // record. Taking it here, not after a long first seed, means a release check never reads a
449
+ // seeding writer as one with no version and restarts it every interval.
450
+ if (s === "resyncing" || s === "connecting")
451
+ takeOwnershipOfRecord();
416
452
  // A "live" that does NOT follow a re-seed is just the socket coming up — the warm failing
417
453
  // loop passes through it every cycle, so it must not reset the count (CTC-2499).
418
454
  if (s === "resyncing") {
@@ -544,6 +580,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
544
580
  return 0;
545
581
  }
546
582
  function cmdStop(ctx, dbPath) {
583
+ rmSync(pendingRestartPathFor(ctx.home), { force: true });
547
584
  const pid = readPidfile(dbPath);
548
585
  if (pid === null) {
549
586
  ctx.stdout(`no pidfile at ${pidfilePath(dbPath)} — nothing to stop (a foreground writer is stopped with Ctrl-C)`);