@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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.31
4
+
5
+ New: `catalyst update` is one release check for everything Catalyst installed on this machine. `catalyst update --check` lists each component (the runner images, the CLI, the Cloud and Dev skills, the Claude plugin form of the Cloud skills, the local replica and each registered project's SDK) with its installed and approved versions, and `catalyst update` moves them to the approved release. `catalyst update policy <component> on|off` holds or releases one component. `catalyst runner update --check` and `catalyst runner auto-update on|off` still work and now cover the same components. The CLI updates itself without breaking the command that is running. If that update fails, the report says so, keeps you on the version you have, and tries again at the next release check. Copied skills never move back to an older version. A running local replica comes back on the new CLI with its synced data. When it cannot, `catalyst replica status` says why and where the data is kept. The Claude plugin moves only to the approved version, and after a move the report tells you to run `/reload-plugins` or restart Claude Code. `catalyst update --json` has a new field, `approvedSkills`, naming the version, repository and full commit each skills pack was approved at. It is `null` when no approval applies.
6
+
7
+ A busy self-hosted machine no longer waits to go idle before it takes a release. When the new release can read the work records the current one keeps, `catalyst runner update` restarts the machine's supervisor with its running work left in place. Otherwise it waits for an idle machine, as before. An old runner image that running work still used is removed by a later update.
8
+
9
+ ## 0.16.30
10
+
11
+ An agent seat can now hold a ticket while it works on it. `catalyst lease take <ticket> --agent <seat>` takes the ticket for that seat for a set time (`--ttl <minutes>`). `catalyst lease renew <ticket>` keeps it, and each renewal names one piece of progress: a commit (`--commit`), an artifact (`--artifact`), a finding (`--finding`) or a pull request (`--pr`). `catalyst lease release <ticket>` hands it back, and `catalyst lease show <ticket>` prints who holds it. A refused renewal, for example one that names no new progress, prints the reason and exits 1. A renewal or release refused because the lease already lapsed, or because another seat took the ticket, forgets the lease this machine stored. The machine stores only the seat name and the lease generation, in a file only you can read. New: `catalyst work available` lists, per team, the tickets nobody holds, with the last holder and their progress, and the open pull requests no live lease covers, drafts included. Both commands use your login. Setup run unattended now says what it skipped and names the exact flags each skipped choice needs. The skill references now say how to see whether a repository is paused, and who can pause or resume it.
12
+
3
13
  ## 0.16.29
4
14
 
5
15
  The CLI now tells Catalyst Cloud which release you have installed: each request to Catalyst Cloud carries the installed version, and so does `catalyst watch` when it connects. Catalyst Cloud can then tell an out-of-date CLI from a current one. Nothing about your requests changes otherwise. On a Mac self-hosted machine, setup now installs a small agent that measures the Mac's disk, so the machine can take work instead of showing zero free slots. When a machine still shows zero free slots, setup and `catalyst ready` now say why: its disk has not been measured yet, or its free space is below the floor (25 GiB by default), with the numbers and the fix. An updated disk sampler also takes effect straight away: setup reloads the running disk agent whenever the sampler script changes, not only when its schedule file does.
package/README.md CHANGED
@@ -42,7 +42,7 @@ The two packs have different jobs and independent versions. Omit `-g` for a proj
42
42
 
43
43
  <details><summary><strong>Alternative for Claude Code: the plugin marketplace</strong></summary>
44
44
 
45
- The plugin installs this pack's same `skills/` tree as a managed bundle that updates when we ship. It needs a GitHub SSH key, and it does not load into the session you are already in — run `/reload-plugins` or restart afterwards. Pick one rail for this pack; installing both leaves you with every skill twice. The development pack has its own optional Claude plugin, `catalyst-dev@catalyst-dev-skills`.
45
+ The plugin installs this pack's same `skills/` tree as a managed bundle, and `catalyst update` moves it to the version your workspace approved (see [Updating](#updating)). It needs a GitHub SSH key, and it does not load into the session you are already in — run `/reload-plugins` or restart afterwards. Pick one rail for this pack; installing both leaves you with every skill twice. The development pack has its own optional Claude plugin, `catalyst-dev@catalyst-dev-skills`.
46
46
 
47
47
  ```
48
48
  /plugin marketplace add coalesce-labs/catalyst-cloud-skills
@@ -298,13 +298,16 @@ The package pins the contract range `1.x || 2.x`, recorded in `package.json` und
298
298
  - `~/.config/catalyst-cloud/customer.json`, written with mode `0600`, holding your personal key, who you are, and the CLI path.
299
299
  - `~/.config/catalyst-cloud/contract.json`, your account's cached contract.
300
300
  - `~/.config/catalyst-cloud/published.json`, the cached answer to "what is the newest release" — no credential in it.
301
+ - After `catalyst update` moves the CLI: `~/.config/catalyst-cloud/cli-runtimes/`, one directory per CLI version it installed and `active.json` naming the one your `catalyst` command runs.
301
302
  - Only if you start them: `~/.config/catalyst-cloud/replica.db` with its `.pid`, `.writer.lock` and `.writer.state` sidecars, `$XDG_STATE_HOME/catalyst/events/<tenant>/backbone/` (or the home-directory fallback) with bounded daily event segments, and `~/.config/catalyst-cloud/watch-cursor.json`.
302
303
 
303
304
  The skill files themselves are written by whichever install command you ran, in that tool's own location. Your personal key goes into that one config file and nowhere else.
304
305
 
305
306
  ## Updating
306
307
 
307
- A plugin install updates when we ship. Skills copied by `npx skills add` do not. After the source and destination check in Install, re-run the Cloud add command to refresh existing skills and pick up new ones. Update the CLI with `npm install -g @catalyst-cloud/cli@latest && catalyst login` — the re-login rewrites the CLI path the skills spawn, so they stop running the old bundle. To run one command against the latest publish without installing, use `npx -p @catalyst-cloud/cli@latest catalyst login`. The next `catalyst` run prints a one-line notice:
308
+ `catalyst update --check` lists each installed component beside the version your workspace approved, says which one is behind and why, and changes nothing. Add `--project <absolute path>` to include a project's SDK; the report gives the one command that moves it, `catalyst update sdk --project <path>`. A project's files change only when you run that command, or after you turn its SDK updates on with `catalyst update policy sdk on --project <path>`. `catalyst update` moves the runner images, the CLI, the Cloud skills (copied on this machine or installed as the Claude plugin), and a running local replica to the approved versions. The new CLI is installed beside the old one, and your `catalyst` command runs it from then on, at the same path. A running replica restarts on it and keeps its synced data; a stopped one stays stopped. The Claude plugin moves through Claude Code, and only when its marketplace serves the approved version; run `/reload-plugins` or restart Claude Code afterwards. When the release check cannot find the `claude` command, it changes nothing and prints the one command that moves the plugin. The development skills are not on the approved release yet: the report lists their version as unknown and gives no command for them, so update them the way you installed them. On a machine whose runner checks for releases every 15 minutes (`catalyst runner auto-update on`), that check moves the same components by itself. Otherwise they move when you run `catalyst update`. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on. Add `--json` for a machine-readable report.
309
+
310
+ A CLI older than `catalyst update` cannot move itself: install it once with `npm install -g @catalyst-cloud/cli@latest && catalyst login`. The re-login rewrites the CLI path the skills spawn, so they stop running the old bundle. To run one command against the latest publish without installing, use `npx -p @catalyst-cloud/cli@latest catalyst login`. The next `catalyst` run prints a one-line notice:
308
311
 
309
312
  ```
310
313
  [catalyst] updated 0.1.1 → 0.2.0: <that version's CHANGELOG.md summary> · update with: npm install -g @catalyst-cloud/cli@latest && catalyst login
@@ -312,7 +315,7 @@ A plugin install updates when we ship. Skills copied by `npx skills add` do not.
312
315
 
313
316
  A `customer.json` written by an older bundle is still read unchanged; it gains the CLI path and the cached contract the next time you run `catalyst login`.
314
317
 
315
- `catalyst ready` also says when either the installed skills or the CLI is behind the latest publish, naming both versions and the command for each; it is a note, never a failure, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup.
318
+ `catalyst ready` also says when either the installed skills or the CLI is behind, naming both versions and the command for each. It compares them with the approved release this machine last read, and only a machine that has not read one yet compares them with the latest publish. It is a note, never a failure, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup.
316
319
 
317
320
  ## Uninstalling
318
321
 
@@ -325,6 +328,7 @@ catalyst replica stop
325
328
  rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/published.json ~/.config/catalyst-cloud/watch-cursor.json
326
329
  rm -f ~/.config/catalyst-cloud/replica.db ~/.config/catalyst-cloud/replica.db.pid ~/.config/catalyst-cloud/replica.db.writer.lock ~/.config/catalyst-cloud/replica.db.writer.state
327
330
  rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/catalyst/events"
331
+ rm -rf ~/.config/catalyst-cloud/cli-runtimes
328
332
  npm uninstall -g @catalyst-cloud/cli
329
333
  ```
330
334
 
package/bin/launch.js CHANGED
@@ -32,6 +32,7 @@
32
32
  // scripts spawn this CLI constantly, and doubling process startup on every call is a real cost.
33
33
  // • ambient unsupported, no pin -> print the verdict and the ONE command; exit 1, never a raw
34
34
  // module-resolution error.
35
+ import { basename } from "node:path";
35
36
  import { fileURLToPath } from "node:url";
36
37
 
37
38
  /** The one stderr line the deprecated name prints in addition to what the program prints. */
@@ -52,6 +53,7 @@ export const DEPRECATED_NAME_LINE =
52
53
  export async function launch(invokedAs, binUrl) {
53
54
  const argv = process.argv.slice(2);
54
55
  const deprecated = invokedAs === "catalyst-skills";
56
+ if (argv[0] !== "runtime" && (await runSelectedVersion(invokedAs, binUrl))) return;
55
57
  const handled = argv[0] === "runtime" ? false : await preflight(invokedAs, binUrl, argv, deprecated);
56
58
  if (handled) return;
57
59
  const noticeFirst = deprecated && process.stderr.isTTY === true;
@@ -66,6 +68,32 @@ export async function launch(invokedAs, binUrl) {
66
68
  });
67
69
  }
68
70
 
71
+ /**
72
+ * CTC-6708 — a release check installs the approved CLI in its own directory and records it as the
73
+ * selected version (dist/update-cli.js). This command stays where npm put it, so scheduled jobs and
74
+ * saved launcher paths keep working: when the selected version is newer than its own, it loads that
75
+ * version's bin in this same process (no second process, so a pid, a signal and an exit code are the
76
+ * selected version's own). The selected version's launcher finds nothing newer and runs. Any doubt
77
+ * (no record, an unreadable one, a directory that does not hold that version) runs this version, as
78
+ * before. A release check that runs a staged version to check it sets CATALYST_CLI_ACTIVE_RUNTIME
79
+ * to that bin's path, so that run never selects. Returns true when it already handled the invocation.
80
+ */
81
+ async function runSelectedVersion(invokedAs, binUrl) {
82
+ if (process.env.CATALYST_CLI_ACTIVE_RUNTIME === fileURLToPath(binUrl)) return false;
83
+ const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/";
84
+ let bin = null;
85
+ try {
86
+ const [updateCli, config] = await Promise.all([import("../dist/update-cli.js"), import("../dist/config.js")]);
87
+ bin = updateCli.activeRuntimeBin(home, config.readManifest().version, basename(fileURLToPath(binUrl)));
88
+ } catch {
89
+ return false;
90
+ }
91
+ if (!bin) return false;
92
+ const { pathToFileURL } = await import("node:url");
93
+ await import(pathToFileURL(bin).href);
94
+ return true;
95
+ }
96
+
69
97
  /** Returns true when it already handled (and exited) the invocation. */
70
98
  async function preflight(invokedAs, binUrl, args, deprecated) {
71
99
  const policy = process.env.CATALYST_SKILLS_RUNTIME ?? "auto";
@@ -0,0 +1,210 @@
1
+ // approved-release.ts — the release Catalyst approved for every component an install keeps, read
2
+ // once per release check (CTC-6708). The approved record names each release-train member's exact
3
+ // version together, so the CLI's "newest" answer comes from it, never from npm's `latest` tag.
4
+ //
5
+ // Two transports carry the same record:
6
+ // - a signed-in CLI reads the member route `GET /api/v1/releases/approved`;
7
+ // - an enrolled machine's desired-release answer (`GET /api/v1/hosts/desired-release`, read inside
8
+ // the supervisor container by runner-update.ts) carries it as `release.components`, beside the
9
+ // digest-pinned images it already names. The host credential never leaves Docker.
10
+ //
11
+ // Parsing is strict: exact stable versions only (no tags, ranges or pre-releases), every version on
12
+ // the record's declared train line, skills packs pinned to one of two known repositories at a full
13
+ // commit. A record on another train is reported and never applied: automatic moves stay on the
14
+ // MAJOR.MINOR line this CLI ships on. The last record read is cached for diagnostics only; a failed
15
+ // read never applies a cached record.
16
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { configDirFor, loadConfig, readManifest } from "./config.js";
19
+ import { CliError, MeError } from "./errors.js";
20
+ import { isRecord, writePrivateAtomic } from "./runner-update.js";
21
+ import { apiClient } from "./transport.js";
22
+ export const APPROVED_RELEASE_PATH = "/api/v1/releases/approved";
23
+ export const APPROVED_RELEASE_CACHE_FILE = "approved-release.json";
24
+ /** Train members the approved record may name. Only some are installed on a customer's machine;
25
+ * the rest are inventory metadata and never something this CLI installs. */
26
+ export const RELEASE_MEMBERS = [
27
+ "cli",
28
+ "forwarder",
29
+ "plugin",
30
+ "sdk",
31
+ "sdk-replica-node",
32
+ "sdk-replica-browser",
33
+ "schema",
34
+ "replicate",
35
+ "read-model",
36
+ "design",
37
+ "installer",
38
+ ];
39
+ export const SKILL_PACKS = ["cloud", "dev"];
40
+ /** The only repository each pack may come from. A record naming another is refused whole. */
41
+ export const SKILL_PACK_REPOSITORIES = {
42
+ cloud: "coalesce-labs/catalyst-cloud-skills",
43
+ dev: "coalesce-labs/catalyst-dev-skills",
44
+ };
45
+ const STABLE_VERSION = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
46
+ const RELEASE_LINE = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
47
+ const COMMIT = /^[0-9a-f]{40}$/;
48
+ const APPROVAL_ID = /^[A-Za-z0-9._:-]{1,128}$/;
49
+ const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
50
+ /** An exact stable version, or null. Unlike skills.ts's lenient `semverOlder`, this is the authority
51
+ * check: `0.16.28` passes; `latest`, `^0.16.28`, `0.16.28-rc.1`, `v0.16.28` and `0.16` do not. */
52
+ export function exactVersion(value) {
53
+ return typeof value === "string" && STABLE_VERSION.test(value) ? value : null;
54
+ }
55
+ /** MAJOR.MINOR of an exact version. */
56
+ export function trainOf(version) {
57
+ return version.split(".").slice(0, 2).join(".");
58
+ }
59
+ /** Compare two exact versions: negative when `a` is older, 0 when equal, positive when newer. */
60
+ export function compareVersions(a, b) {
61
+ const pa = a.split(".").map(Number);
62
+ const pb = b.split(".").map(Number);
63
+ for (let i = 0; i < 3; i++) {
64
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
65
+ if (d !== 0)
66
+ return d;
67
+ }
68
+ return 0;
69
+ }
70
+ /** The train this CLI ships on; automatic updates never leave it. */
71
+ export function supportedReleaseLine() {
72
+ return trainOf(readManifest().version);
73
+ }
74
+ /** Parse the approved record from an unknown body. Any malformed field refuses the whole record. */
75
+ export function parseApprovedRelease(input) {
76
+ const invalid = { ok: false, reason: "response_invalid" };
77
+ if (!isRecord(input) || input.schemaVersion !== 1)
78
+ return invalid;
79
+ if (typeof input.approvalId !== "string" || !APPROVAL_ID.test(input.approvalId))
80
+ return invalid;
81
+ if (!posInt(input.generation) || !posInt(input.approvedAtMs))
82
+ return invalid;
83
+ if (typeof input.releaseLine !== "string" || !RELEASE_LINE.test(input.releaseLine))
84
+ return invalid;
85
+ const releaseLine = input.releaseLine;
86
+ if (!isRecord(input.members))
87
+ return invalid;
88
+ const members = {};
89
+ for (const member of RELEASE_MEMBERS) {
90
+ const value = input.members[member];
91
+ if (value === undefined)
92
+ continue;
93
+ const version = exactVersion(value);
94
+ if (version === null || trainOf(version) !== releaseLine)
95
+ return invalid;
96
+ members[member] = version;
97
+ }
98
+ if (members.cli === undefined)
99
+ return invalid;
100
+ const skills = {};
101
+ if (input.skills !== undefined) {
102
+ if (!isRecord(input.skills))
103
+ return invalid;
104
+ for (const pack of SKILL_PACKS) {
105
+ const value = input.skills[pack];
106
+ if (value === undefined)
107
+ continue;
108
+ if (!isRecord(value))
109
+ return invalid;
110
+ const version = exactVersion(value.version);
111
+ if (version === null || trainOf(version) !== releaseLine)
112
+ return invalid;
113
+ if (value.repository !== SKILL_PACK_REPOSITORIES[pack])
114
+ return invalid;
115
+ if (typeof value.commit !== "string" || !COMMIT.test(value.commit))
116
+ return invalid;
117
+ skills[pack] = { version, repository: value.repository, commit: value.commit };
118
+ }
119
+ }
120
+ return {
121
+ ok: true,
122
+ release: { approvalId: input.approvalId, generation: input.generation, approvedAtMs: input.approvedAtMs, releaseLine, members, skills },
123
+ };
124
+ }
125
+ /** Read the member route with this machine's sign-in. Never throws; every failure is a reason. */
126
+ export async function readMemberApproval(ctx) {
127
+ let cfg;
128
+ try {
129
+ cfg = loadConfig(ctx.home);
130
+ }
131
+ catch {
132
+ return { kind: "unavailable", reason: "signed_out" };
133
+ }
134
+ if (!cfg)
135
+ return { kind: "unavailable", reason: "signed_out" };
136
+ try {
137
+ const res = await apiClient(cfg, ctx).getJson(APPROVED_RELEASE_PATH, { accept: [404] });
138
+ if (res.status === 404)
139
+ return { kind: "unavailable", reason: "not_provided" };
140
+ const body = isRecord(res.body) && res.body.ok === true ? res.body.release : res.body;
141
+ const parsed = parseApprovedRelease(body);
142
+ return parsed.ok ? { kind: "approved", release: parsed.release, source: "network" } : { kind: "unavailable", reason: parsed.reason };
143
+ }
144
+ catch (err) {
145
+ if (err instanceof MeError && err.kind === "network")
146
+ return { kind: "unavailable", reason: "unreachable" };
147
+ if (err instanceof CliError && (err.status === 401 || err.status === 403))
148
+ return { kind: "unavailable", reason: "credential_refused" };
149
+ if (err instanceof MeError && err.kind === "shape")
150
+ return { kind: "unavailable", reason: "response_invalid" };
151
+ return { kind: "unavailable", reason: "unreachable" };
152
+ }
153
+ }
154
+ /** The component record a host's desired-release answer carries, from the same read the image
155
+ * update used. A legacy answer with images only reads `not_provided`; a record whose generation is
156
+ * not the image generation reads `envelope_mismatch`, so the two halves never mix snapshots. */
157
+ export function hostApproval(read) {
158
+ if (read.kind === "no_credential")
159
+ return { kind: "unavailable", reason: "host_unreadable" };
160
+ if (read.kind === "unreachable")
161
+ return { kind: "unavailable", reason: "unreachable" };
162
+ if (read.status === 401 || read.status === 403)
163
+ return { kind: "unavailable", reason: "credential_refused" };
164
+ // 404 and 503 are the cloud saying it has no approved release to name; anything else failed.
165
+ if (read.status === 404 || read.status === 503)
166
+ return { kind: "unavailable", reason: "not_provided" };
167
+ if (read.status !== 200)
168
+ return { kind: "unavailable", reason: "unreachable" };
169
+ if (!isRecord(read.body) || read.body.ok !== true || !isRecord(read.body.release))
170
+ return { kind: "unavailable", reason: "response_invalid" };
171
+ const release = read.body.release;
172
+ if (release.components === undefined)
173
+ return { kind: "unavailable", reason: "not_provided" };
174
+ const parsed = parseApprovedRelease(release.components);
175
+ if (!parsed.ok)
176
+ return { kind: "unavailable", reason: parsed.reason };
177
+ if (parsed.release.generation !== release.generation)
178
+ return { kind: "unavailable", reason: "envelope_mismatch" };
179
+ return { kind: "approved", release: parsed.release, source: "host" };
180
+ }
181
+ export function approvalCachePathFor(home) {
182
+ return join(configDirFor(home), APPROVED_RELEASE_CACHE_FILE);
183
+ }
184
+ /** The last approved record an applying check read, for an offline diagnostic. Never applied. */
185
+ export function readApprovalCache(home) {
186
+ const path = approvalCachePathFor(home);
187
+ if (!existsSync(path))
188
+ return null;
189
+ try {
190
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
191
+ if (!isRecord(parsed) || !posInt(parsed.observedAtMs))
192
+ return null;
193
+ const release = parseApprovedRelease(parsed.release);
194
+ return release.ok ? { observedAtMs: parsed.observedAtMs, release: release.release } : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /** Best effort: a read-only home keeps working without its diagnostic cache. */
201
+ export function writeApprovalCache(home, cache) {
202
+ try {
203
+ const path = approvalCachePathFor(home);
204
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
205
+ writePrivateAtomic(path, `${JSON.stringify({ observedAtMs: cache.observedAtMs, release: { schemaVersion: 1, ...cache.release } }, null, 2)}\n`);
206
+ }
207
+ catch {
208
+ // the diagnostic cache is optional
209
+ }
210
+ }
package/dist/args.js CHANGED
@@ -29,6 +29,11 @@ export const FLAG_TABLES = {
29
29
  yes: { value: false, help: "uninstall: confirm without a terminal" },
30
30
  "delete-checkouts": { value: false, help: "uninstall: also delete the repository and thoughts checkouts" },
31
31
  },
32
+ update: {
33
+ check: { value: false, help: "read the approved release and list every component against it, without changing anything" },
34
+ project: { value: true, help: "also check this project's SDK (an absolute path); with policy sdk, the project the setting is for" },
35
+ dir: { value: true, help: "the runner directory (default: the one setup created)" },
36
+ },
32
37
  login: {
33
38
  "start-replica": {
34
39
  value: false,
@@ -508,6 +513,19 @@ export const FLAG_TABLES = {
508
513
  help: "with --remove: answer the question yes (a run with no terminal only reports otherwise)",
509
514
  },
510
515
  },
516
+ lease: {
517
+ agent: { value: true, help: "the seat's name, 1 to 40 of a-z, 0-9 and - (take: required; renew, release: when this machine holds the ticket for several seats)" },
518
+ ttl: { value: true, help: "take, renew: minutes until the lease lapses without a renewal (the cloud keeps it between 5 and 60; default 30)" },
519
+ commit: { value: true, help: "renew: the full 40-character sha of a commit you pushed" },
520
+ artifact: { value: true, help: "renew: a thoughts path or check-run id you wrote" },
521
+ finding: { value: true, help: "renew: a comment or review id you posted" },
522
+ pr: { value: true, help: "renew: the number of a pull request you opened" },
523
+ note: { value: true, help: "renew: a few words about the progress (not part of what counts as new)" },
524
+ generation: { value: true, help: "renew, release: the generation the take printed (default: the one this machine stored)" },
525
+ },
526
+ work: {
527
+ team: { value: true, help: "available: one team's key (default: every team in the workspace contract)" },
528
+ },
511
529
  repo: {
512
530
  reason: { value: true, help: "pause: required; resume: optional; 1 to 500 characters after trimming (owner or admin)" },
513
531
  write: {
@@ -562,6 +580,7 @@ export const FLAG_TABLES = {
562
580
  export const VERB_USAGE = {
563
581
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
564
582
  runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--json]",
583
+ update: "update [--check] [--project <absolute path>] [--dir <runner directory>] | update sdk --project <absolute path> | update policy [<component> on|off] | update policy sdk on|off --project <absolute path> [--json] (components: runner, supervisor, watchdog, cli, skills-cloud, skills-dev, replica)",
565
584
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
566
585
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
567
586
  onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--remove-local-data] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
@@ -594,6 +613,8 @@ export const VERB_USAGE = {
594
613
  messages: "messages read --ticket CTC-N [--since SEQUENCE] [--json]",
595
614
  capabilities: "capabilities [--json]",
596
615
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
616
+ lease: "lease <take <ticket> --agent <seat> [--ttl <minutes>] | renew <ticket> (--commit <sha>|--artifact <path or id>|--finding <id>|--pr <number>) [--note <text>] [--agent <seat>] [--generation <n>] | release <ticket> [--agent <seat>] [--generation <n>] | show <ticket>> [--json] (a seat's ticket lease: take, renew with new progress, release, read; needs login)",
617
+ work: "work available [--team K] [--json] (per team: tickets nobody holds, with the last holder and progress, and open pull requests no live lease covers, drafts included)",
597
618
  repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
598
619
  legacy: "legacy [--remove [--data] [--yes]] [--json]",
599
620
  connections: "connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
@@ -8,6 +8,9 @@ export const CAPABILITIES = [
8
8
  { verb: "hosts", does: "list this account's machines, current use and live capacity", needs: "member", routes: [], since: "0.16.0" },
9
9
  { verb: "hosts remove|rename", does: "remove or rename a self-hosted machine in this account", needs: "admin", routes: [], since: "0.16.0" },
10
10
  { verb: "runner update|auto-update", does: "move this machine's self-hosted runner to the release Catalyst approved, and check for one every 15 minutes", needs: "member", routes: [], since: "0.16.16" },
11
+ { verb: "update", does: "list every installed component against the release Catalyst approved, move the runner images to it, and name the command for the rest", needs: "member", routes: [], since: "0.16.31" },
12
+ { verb: "update sdk", does: "move one project's Catalyst SDK to the approved versions with its own package manager, when the person asks", needs: "member", routes: [], since: "0.16.31" },
13
+ { verb: "update policy", does: "turn automatic updates off or on for one installed component, or set a project's SDK to notify only", needs: "member", routes: [], since: "0.16.31" },
11
14
  { verb: "runner status|stop|start", does: "stop this machine's self-hosted runner taking work, or start it again with the same registration", needs: "member", routes: [], since: "0.16.27" },
12
15
  { verb: "runner uninstall", does: "remove this machine's self-hosted runner and revoke its registration and runner key", needs: "admin", routes: [], since: "0.16.27" },
13
16
  // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
@@ -32,6 +35,8 @@ export const CAPABILITIES = [
32
35
  { verb: "project wip-limit set", does: "set a project's new-start WIP limit (0 to 9999, or back to the workspace default)", needs: "admin", routes: [agent("POST", "team-wip-limit")], since: "0.13.1" },
33
36
  { verb: "repo status", does: "list visible repositories with their state, pause details and projects", needs: "member", routes: [agent("GET", "repos")], since: "0.16.27" },
34
37
  { verb: "repo pause|resume", does: "pause or resume a repository as a workspace owner or admin", needs: "admin", routes: [agent("GET", "repos"), agent("POST", "repos/pause"), agent("POST", "repos/resume")], since: "0.16.27" },
38
+ { verb: "lease take|renew|release|show", does: "hold the ticket a seat works through a ticket lease that lapses without new progress, instead of a label", needs: "member", routes: [agent("POST", "ticket-lease/take"), agent("POST", "ticket-lease/renew"), agent("POST", "ticket-lease/release"), agent("GET", "ticket-lease")], since: "0.16.30" },
39
+ { verb: "work available", does: "list tickets nobody holds, with the last holder and progress, and open pull requests no lease covers", needs: "member", routes: [], since: "0.16.30" },
35
40
  { verb: "repo agents-block", does: "add or refresh the one Catalyst block in a checkout's AGENTS.md, in the working tree, for the person to commit and open as a pull request", needs: "member", routes: [], since: "0.13.1" },
36
41
  { verb: "repo agent-setup", does: "read a checkout's agent setup (AGENTS.md, CLAUDE.md, .agents and .claude skills and rules) and, on request, make it portable in the working tree", needs: "member", routes: [], since: "0.13.1" },
37
42
  { verb: "env shell", does: "open this repository's environment on this computer: Catalyst's image, the declared toolchain and variables (needs Docker; no login for Catalyst's image)", needs: "member", routes: [], since: "0.16.20" },
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { configDirFor, publishedCachePathFor } from "./config.js";
5
- import { latestPublishedVersion } from "./published.js";
5
+ import { newestCliVersion } from "./published.js";
6
6
  import { semverOlder } from "./semver.js";
7
7
  const DAY_MS = 24 * 60 * 60 * 1000;
8
8
  const RELEASE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
@@ -62,12 +62,15 @@ export async function maybePrintPublishedUpdateNotice(ctx, options) {
62
62
  rmSync(temporary, { force: true });
63
63
  }
64
64
  // Record the attempt before the bounded lookup, including failures, so offline commands do
65
- // not repeatedly contact npm. Reuse ready's anonymous request and version cache.
66
- const reply = await latestPublishedVersion(ctx);
65
+ // not repeatedly contact npm. Reuse ready's anonymous request and version cache. Once this
66
+ // install has read an approved release, that is the version named (CTC-6708).
67
+ const reply = await newestCliVersion(ctx);
67
68
  if (reply.latest &&
68
69
  RELEASE.test(reply.latest) &&
69
70
  semverOlder(options.version, reply.latest)) {
70
- ctx.stderr(`Catalyst ${reply.latest} is out. Update with: npm install -g @catalyst-cloud/cli@latest`);
71
+ ctx.stderr(reply.source === "approved"
72
+ ? `Catalyst ${reply.latest} is approved for this workspace. Update with: catalyst update`
73
+ : `Catalyst ${reply.latest} is out. Update with: npm install -g @catalyst-cloud/cli@latest`);
71
74
  }
72
75
  }
73
76
  catch {
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cmdHosts } from "./hosts.js";
2
2
  import { cmdRunner } from "./runner-command.js";
3
+ import { cmdUpdate } from "./update-command.js";
3
4
  import { cmdSeat } from "./seat.js";
4
5
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
5
6
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
@@ -21,6 +22,7 @@ import { openBrowserChecked as defaultOpenBrowser } from "./browser.js";
21
22
  import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning, } from "./execution.js";
22
23
  import { cmdQuery } from "./query.js";
23
24
  import { maybePrintPublishedUpdateNotice } from "./cli-update-notice.js";
25
+ import { semverOlder } from "./semver.js";
24
26
  import { cmdReady } from "./ready.js";
25
27
  import { cmdReplica } from "./replica.js";
26
28
  import { cmdRuntime } from "./runtime-verb.js";
@@ -46,6 +48,7 @@ import { cmdTeam } from "./team.js";
46
48
  import { cmdCapabilities } from "./capabilities.js";
47
49
  import { cmdProject } from "./project.js";
48
50
  import { cmdRepo } from "./repo.js";
51
+ import { cmdLease, cmdWork } from "./lease.js";
49
52
  import { cmdLegacy } from "./legacy.js";
50
53
  import { cmdIdentity } from "./identity.js";
51
54
  import { cmdMessages } from "./messages.js";
@@ -89,6 +92,7 @@ export function usageText() {
89
92
  " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
90
93
  " catalyst runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] (this machine's self-hosted runner)",
91
94
  " catalyst runner update [--check] | runner auto-update [status|on|off] (move this machine's runner to the approved release)",
95
+ " catalyst update [--check] [--project <path>] | update policy [<component> on|off] (every installed component against the approved release)",
92
96
  " catalyst mcp add|list|remove (vault references only) | mcp status <name> (a server's state, grants and last 50 tool calls)",
93
97
  " catalyst contract [--refresh] [--path <a.b.c>]",
94
98
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
@@ -113,6 +117,8 @@ export function usageText() {
113
117
  " catalyst repo pause <owner/name> --reason <text> | repo resume <owner/name> [--reason <text>] (owner or admin; reason: 1 to 500 trimmed characters)",
114
118
  " catalyst repo status [--json] (visible workspace repositories and their paused state; needs login)",
115
119
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
120
+ " catalyst lease <take <ticket> --agent <seat> [--ttl <minutes>]|renew <ticket> --commit|--artifact|--finding|--pr <ref> [--note <text>]|release <ticket>|show <ticket>> [--json] (a seat's ticket lease; needs login)",
121
+ " catalyst work available [--team K] [--json] (tickets nobody holds and open pull requests no lease covers)",
116
122
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
117
123
  " catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
118
124
  " catalyst messages read --ticket CTC-N [--since SEQUENCE] [--json] (messages sent to a ticket on your teams)",
@@ -195,6 +201,10 @@ function maybePrintUpdateNotice(args, ctx) {
195
201
  const manifest = readManifest();
196
202
  if (cfg.lastSkillBundleVersion === manifest.version)
197
203
  return;
204
+ // CTC-6741: a newer CLI selected by `catalyst update` records its own bundle here, and the older
205
+ // npm-installed one still runs for `catalyst runtime`. Copied skills only ever move forward.
206
+ if (cfg.lastSkillBundleVersion && semverOlder(manifest.version, cfg.lastSkillBundleVersion))
207
+ return;
198
208
  const previous = cfg.lastSkillBundleVersion;
199
209
  if (previous) {
200
210
  const entry = parseChangelogEntry(readChangelog(), manifest.version);
@@ -548,6 +558,10 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
548
558
  return await cmdProject(args, ctx);
549
559
  case "repo":
550
560
  return await cmdRepo(args, ctx);
561
+ case "lease":
562
+ return await cmdLease(args, ctx);
563
+ case "work":
564
+ return await cmdWork(args, ctx);
551
565
  case "legacy":
552
566
  return await cmdLegacy(args, ctx, { localSyncSelected: savedLocalSync(ctx), ...deps.legacy });
553
567
  case "identity":
@@ -558,6 +572,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
558
572
  return await cmdHosts(args, ctx, deps.hosts);
559
573
  case "runner":
560
574
  return await cmdRunner(args, ctx, deps.runner);
575
+ case "update":
576
+ return await cmdUpdate(args, ctx, deps.update);
561
577
  case "connections":
562
578
  return await cmdConnections(args, ctx, deps.connections ?? {});
563
579
  default:
@@ -624,8 +640,11 @@ const VERB_HELP_KNOWN = Object.fromEntries([
624
640
  "capabilities",
625
641
  "project",
626
642
  "repo",
643
+ "lease",
644
+ "work",
627
645
  "legacy",
628
646
  "onboard",
647
+ "update",
629
648
  ].map((v) => [v, true]));
630
649
  /** CTC-4680: the Pixel Nucleus mark belongs to `catalyst setup` alone. `setup` runs the onboard
631
650
  * flow, so this is read from the verb typed, before setup becomes onboard. */