@catalyst-cloud/cli 0.16.36 → 0.16.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.38
4
+
5
+ On a Mac with thoughts custody, a runner update agent that 0.16.35 or 0.16.36 moved kept failing every update with `custody_restore_failed:runtime_custody_mismatch`, even after the CLI updated itself to 0.16.37. The scheduled update installs the new CLI beside the old one, not globally, so the 0.16.37 repair never ran. Now `catalyst runner update` checks which node it runs under. When that differs from the node the custody was set up with, it runs the same command again under the custody's node and exits with that run's result. The agent file is left as it is, and no run in progress is interrupted. `--check` and `--fleet`, a Mac without thoughts custody, and Linux are unchanged.
6
+
7
+ `catalyst setup --only codex-attribution --codex-home <dir>` gives a second Codex home the default home's telemetry exporter, so its usage is reported too. It does this only for a home logged in to one of the workspace's coding accounts that has no exporter of its own. Before it changes that home's `config.toml`, it keeps a copy beside it, and the new file is never more readable than the original. A `config.toml` that is a link is left alone. `--dry-run` shows the exporter it would copy with every value except the endpoint, protocol and environment shown as `<hidden>`. `catalyst setup --remove-codex-attribution --codex-home <dir>` takes the copied exporter back out.
8
+
9
+ ## 0.16.37
10
+
11
+ On a Mac with thoughts custody, every runner update failed after 0.16.35 or 0.16.36 pointed the runner update agent at a CLI installed under a different node. The update log showed `custody_restore_failed:runtime_custody_mismatch`. The custody records the node it was set up with, and refuses any other. The update agent now runs the CLI under that recorded node, whether `catalyst runner auto-update on` or the global install writes it. Installing this release globally repairs an agent that was already moved, and keeps the previous agent file beside it, as `<file>.before-0.16.37`. A Mac without thoughts custody, and Linux, are unchanged.
12
+
3
13
  ## 0.16.36
4
14
 
5
15
  `catalyst setup --only codex-attribution` turns on Codex attribution on a machine that is already set up, without running the other setup steps. It adds the codex function to `~/.zshrc` and `~/.bashrc`, and on a Mac the login agent. Before it first changes an rc file, it keeps a byte-for-byte copy beside it, ending in `.before-codex-attribution`, and never overwrites an earlier copy. Running it again changes nothing. `--dry-run` shows exactly what it would add and changes nothing, and `--json` prints the result as one document.
package/README.md CHANGED
@@ -144,7 +144,7 @@ When Codex is on this computer (a `codex` command on your PATH, or `Codex.app` i
144
144
 
145
145
  On a Mac, setup also installs the `dev.catalystcloud.codex-attribution` LaunchAgent. It runs `catalyst codex-env --apply` when you log in, and again whenever Codex signs in to another account in its default home, so apps you start from the Dock or Finder, the Codex desktop app included, carry the same attributes. An app that is already open keeps the environment it started with. It picks up a change the next time it starts. On Linux, setup adds the shell function only. Without Codex, setup adds nothing and says it skipped this. Run `catalyst setup` again after you install Codex to add it.
146
146
 
147
- `catalyst status` ends with one line that says whether this is installed, which coding account the Codex home resolves to, and how old the cached account list is. `catalyst setup --no-shell-hook` skips all of it. On a machine that is already set up, `catalyst setup --only codex-attribution` adds just this and runs no other step. Before it first changes `~/.zshrc` or `~/.bashrc`, it keeps a copy of the file beside it, ending in `.before-codex-attribution`. Run again, it reports each file as unchanged. With `--dry-run` it prints the exact block each file would get and the LaunchAgent file, and changes nothing. `catalyst setup --remove-codex-attribution` removes it and runs nothing else. Each file goes back to exactly what it was before the block was added, the LaunchAgent is unloaded and deleted, and the two attributes it set (the coding account and the machine name) are taken back out of the launch environment. Attributes you set there yourself are kept. A fake HOME gets none of this.
147
+ `catalyst status` ends with one line that says whether this is installed, which coding account the Codex home resolves to, and how old the cached account list is. `catalyst setup --no-shell-hook` skips all of it. On a machine that is already set up, `catalyst setup --only codex-attribution` adds just this and runs no other step. Before it first changes `~/.zshrc` or `~/.bashrc`, it keeps a copy of the file beside it, ending in `.before-codex-attribution`. Run again, it reports each file as unchanged. With `--dry-run` it prints the exact block each file would get and the LaunchAgent file, and changes nothing. A Codex home other than the default one sends no telemetry until its own `config.toml` has an exporter. Name it with `--codex-home <dir>` (repeatable): when it is logged in to one of the workspace's coding accounts and has no exporter of its own, setup copies the default home's `[otel]` tables into it, after keeping a copy of its `config.toml` beside it. A dry run shows those tables with every header value as `<hidden>`. A home that already has an exporter, or whose login is not a workspace coding account, is left as it is. `catalyst setup --remove-codex-attribution` removes it and runs nothing else; with `--codex-home <dir>` it also takes the copied exporter out of that home's `config.toml`. Each file goes back to exactly what it was before the block was added, the LaunchAgent is unloaded and deleted, and the two attributes it set (the coding account and the machine name) are taken back out of the launch environment. Attributes you set there yourself are kept. A fake HOME gets none of this.
148
148
 
149
149
  ### Headless setup (CI, image builds, agent VMs)
150
150
 
@@ -13,7 +13,7 @@ import { dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { compareVersions, exactVersion } from "./approved-release.js";
15
15
  import { LEGACY_PACKAGE_NAME, readManifest } from "./config.js";
16
- import { AUTO_UPDATE_LABEL, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, writeFileAtomic, } from "./runner-auto-update.js";
16
+ import { AUTO_UPDATE_LABEL, custodyNode, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, writeFileAtomic, } from "./runner-auto-update.js";
17
17
  import { CLAUDE_PATH_ENV, resolveClaude } from "./update-plugin.js";
18
18
  /** Long enough for an in-place update (under a minute on a Mac measured 2026-10-24), well inside the
19
19
  * installer's 600-second budget for the whole npm install. */
@@ -47,13 +47,14 @@ function unitProgram(text) {
47
47
  return programOf(unitWords(exec), env("PATH"), env(CLAUDE_PATH_ENV));
48
48
  }
49
49
  function programOf(args, path, claude) {
50
- const [cli, ...rest] = args;
51
- if (cli === undefined || !cli.startsWith("/") || rest.length !== 5)
50
+ const node = args.length === 7 ? (args[0] ?? null) : null;
51
+ const [cli, ...rest] = node === null ? args : args.slice(1);
52
+ if (cli === undefined || !cli.startsWith("/") || (node !== null && !node.startsWith("/")) || rest.length !== 5)
52
53
  return null;
53
54
  const [a, b, c, d, dir] = rest;
54
55
  if (a !== "runner" || b !== "update" || c !== "--auto" || d !== "--dir" || dir === undefined || !dir.startsWith("/"))
55
56
  return null;
56
- return { cli, dir, path, claude };
57
+ return { node, cli, dir, path, claude };
57
58
  }
58
59
  const real = (path) => {
59
60
  try {
@@ -165,12 +166,16 @@ function decide(deps, seen) {
165
166
  return { action: "skipped", reason: "agent_unrecognized" };
166
167
  seen.dir = program.dir;
167
168
  const from = real(program.cli);
168
- if (from !== null && from === real(deps.self))
169
+ // CTC-7030: an agent already on this install is rewritten only when it runs under another node
170
+ // than its thoughts custody was set up with, which would refuse every update.
171
+ const node = platform === "darwin" ? custodyNode(program.dir) : null;
172
+ const current = from !== null && from === real(deps.self);
173
+ if (current && program.node === node)
169
174
  return { action: "skipped", reason: "already_current" };
170
175
  // A program that no longer exists runs nothing, so any working copy is better. One that exists
171
176
  // moves only when it is a catalyst CLI older than this one.
172
- let fromVersion = null;
173
- if (from !== null) {
177
+ let fromVersion = current ? deps.version : null;
178
+ if (from !== null && !current) {
174
179
  fromVersion = cliVersionAt(program.cli);
175
180
  if (fromVersion === null)
176
181
  return { action: "skipped", reason: "agent_cli_unrecognized" };
@@ -195,6 +200,7 @@ function decide(deps, seen) {
195
200
  home: deps.home,
196
201
  uid: deps.uid,
197
202
  cli: deps.self,
203
+ node,
198
204
  path: scheduledPath(deps.self, deps.nodeExecutable, program.path ?? deps.env.PATH),
199
205
  claude: program.claude ?? resolveClaude(deps.home, deps.env),
200
206
  run: deps.run,
@@ -207,7 +213,7 @@ function decide(deps, seen) {
207
213
  }
208
214
  if (status.state !== "on")
209
215
  return { action: "failed", detail: status.detail };
210
- return { action: "repointed", from: program.cli, fromVersion, to: deps.self, backup };
216
+ return { action: "repointed", from: program.cli, fromVersion, to: deps.self, backup, ...(node !== null ? { node } : {}) };
211
217
  }
212
218
  catch (err) {
213
219
  return { action: "failed", detail: (err instanceof Error ? err.message : String(err)).slice(0, 160) };
@@ -215,8 +221,10 @@ function decide(deps, seen) {
215
221
  }
216
222
  /** One line for the person, or null when there is nothing to say. */
217
223
  export function repointLine(result, version) {
218
- if (result.action === "repointed")
219
- return `catalyst: the runner update agent now runs ${result.to} (${version}) instead of ${result.from} (${result.fromVersion ?? "missing"}). The previous agent file is kept at ${result.backup}.`;
224
+ if (result.action === "repointed") {
225
+ const under = result.node === undefined ? "" : `, under ${result.node}, the node this machine's thoughts custody was set up with,`;
226
+ return `catalyst: the runner update agent now runs ${result.to} (${version})${under} instead of ${result.from} (${result.fromVersion ?? "missing"}). The previous agent file is kept at ${result.backup}.`;
227
+ }
220
228
  if (result.action === "skipped" && result.reason === "agent_not_writable")
221
229
  return `catalyst: the runner update agent at ${result.agent} runs an older catalyst, and this install cannot change it. Ask whoever installed it to run: catalyst runner auto-update on`;
222
230
  if (result.action === "skipped" && result.reason === "agent_busy")
package/dist/args.js CHANGED
@@ -104,6 +104,11 @@ export const FLAG_TABLES = {
104
104
  help: "resume at a verified onboarding step",
105
105
  },
106
106
  only: { value: true, help: "run only the named onboarding step; codex-attribution adds only the Codex launch hooks, with no other step" },
107
+ "codex-home": {
108
+ value: true,
109
+ repeat: true,
110
+ help: "codex-attribution: a further Codex home to send telemetry, given the default home's exporter (repeatable; also taken out by --remove-codex-attribution)",
111
+ },
107
112
  yes: { value: false, help: "accept the plan's default choices" },
108
113
  "dry-run": {
109
114
  value: false,
@@ -607,7 +612,7 @@ export const VERB_USAGE = {
607
612
  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> | update policy windows <HH:MM,...>|default [--time-zone <zone>] [--json] (components: runner, supervisor, watchdog, cli, skills-cloud, skills-dev, replica)",
608
613
  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)",
609
614
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
610
- 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-shell-hook] [--no-color] [--yes] [--dry-run] [--json] | onboard --only codex-attribution [--dry-run] [--json] | onboard --remove-codex-attribution [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
615
+ 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-shell-hook] [--no-color] [--yes] [--dry-run] [--json] | onboard --only codex-attribution [--codex-home <dir>]... [--dry-run] [--json] | onboard --remove-codex-attribution [--codex-home <dir>]... [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
611
616
  install: "install [--skills-dir <dir>] [--force]",
612
617
  status: "status",
613
618
  notice: "notice",
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { createSetupRenderer, setupBanner, } from "./setup-render.js";
8
8
  import { existsSync, openSync, closeSync } from "node:fs";
9
9
  import { ReadStream as TerminalInput } from "node:tty";
10
10
  import { Writable } from "node:stream";
11
- import { flagBool, parseArgs, positionals, verbHelp } from "./args.js";
11
+ import { flagBool, flagList, parseArgs, positionals, verbHelp } from "./args.js";
12
12
  import { reportCliVersion } from "./cli-version.js";
13
13
  import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
14
14
  import { loadContract, pickPath } from "./contract.js";
@@ -88,7 +88,7 @@ export function usageText() {
88
88
  " catalyst join ... (deprecated alias of login; removed in the next minor version)",
89
89
  " catalyst install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
90
90
  " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--no-shell-hook] [--yes] [--dry-run] [--json]",
91
- " catalyst setup --only codex-attribution [--dry-run] [--json] (add only the Codex launch hook and login agent, keeping a copy of each rc file it changes)",
91
+ " catalyst setup --only codex-attribution [--codex-home <dir>]... [--dry-run] [--json] (add only the Codex launch hook and login agent, keeping a copy of each rc file it changes; each --codex-home also gets the default home's telemetry exporter)",
92
92
  " catalyst setup --remove-codex-attribution (take out the Codex launch hook and login agent setup added)",
93
93
  ` catalyst ${SETUP_USAGE}`,
94
94
  " catalyst status | notice | me | ready | accounts",
@@ -295,14 +295,18 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
295
295
  throw new UsageError(`--${flag} needs --headless`);
296
296
  if (typeof args.flags.runner === "string" && !onboardHeadlessRequested(args, ctx.env))
297
297
  throw new UsageError("--runner yes|no needs --headless; use --runner or --no-runner interactively");
298
+ // CTC-7028: further Codex homes go with the hooks' own two paths only.
299
+ const codexHomes = flagList(args, "codex-home");
300
+ if (codexHomes.length > 0 && args.flags.only !== "codex-attribution" && args.flags["remove-codex-attribution"] !== true)
301
+ throw new UsageError("--codex-home goes with --only codex-attribution or --remove-codex-attribution");
298
302
  // The Codex launch hooks' removal runs on its own, with no onboarding around it.
299
303
  if (args.flags["remove-codex-attribution"] === true) {
300
304
  if (args.flags["dry-run"] === true)
301
305
  throw new UsageError("--remove-codex-attribution changes files; run it without --dry-run");
302
306
  deps.setupUi?.dispose();
303
- const removal = removeCodexAttribution(ctx, deps.codexAttribution);
307
+ const removal = removeCodexAttribution(ctx, deps.codexAttribution, codexHomes);
304
308
  if (args.json)
305
- ctx.stdout(JSON.stringify({ removed: removal.ok, files: removal.files, agent: removal.agent }));
309
+ ctx.stdout(JSON.stringify({ removed: removal.ok, files: removal.files, agent: removal.agent, homes: removal.homes }));
306
310
  else
307
311
  for (const line of removal.lines)
308
312
  ctx.stdout(line);
@@ -313,9 +317,9 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
313
317
  if (args.flags["no-shell-hook"] === true)
314
318
  throw new UsageError("--only codex-attribution adds the Codex launch hooks; --no-shell-hook says not to");
315
319
  deps.setupUi?.dispose();
316
- const only = onlyCodexAttribution(ctx, deps.codexAttribution, { dryRun: args.flags["dry-run"] === true });
320
+ const only = onlyCodexAttribution(ctx, deps.codexAttribution, { dryRun: args.flags["dry-run"] === true, codexHomes });
317
321
  if (args.json)
318
- ctx.stdout(JSON.stringify({ ok: only.ok, dryRun: only.dryRun, files: only.files, backups: only.backups, agent: only.agent }));
322
+ ctx.stdout(JSON.stringify({ ok: only.ok, dryRun: only.dryRun, files: only.files, backups: only.backups, agent: only.agent, homes: only.homes }));
319
323
  else
320
324
  for (const line of only.lines)
321
325
  ctx.stdout(line);
@@ -22,7 +22,8 @@
22
22
  import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
23
23
  import { userInfo } from "node:os";
24
24
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
25
- import { ACCOUNT_SLOT_ATTRIBUTE, HOST_NAME_ATTRIBUTE, attrValue, defaultCodexHome, readCodexAccountId, readCodexAccountsCache, resolveSlot, } from "./codex-env.js";
25
+ import { ACCOUNT_SLOT_ATTRIBUTE, HOST_NAME_ATTRIBUTE, attrValue, defaultCodexHome, NO_LOGIN_SLOT, readCodexAccountId, readCodexAccountsCache, resolveSlot, UNREGISTERED_SLOT, } from "./codex-env.js";
26
+ import { hasOtel, maskedOtel, otelTables, withOtel, withoutOtel } from "./codex-otel.js";
26
27
  import { configDirFor, loadConfig } from "./config.js";
27
28
  import { defaultRun, scheduledPath, writeFileAtomic, xml, } from "./runner-auto-update.js";
28
29
  export const CODEX_ATTRIBUTION_LABEL = "dev.catalystcloud.codex-attribution";
@@ -641,6 +642,129 @@ export function setupCodexAttribution(ctx, deps = {}) {
641
642
  }
642
643
  /** The copy `--only codex-attribution` keeps of an rc file before it first changes it. */
643
644
  export const RC_BACKUP_SUFFIX = ".before-codex-attribution";
645
+ /** The workspace's coding-account list as this machine cached it, or null when it is for another
646
+ * workspace, missing, or this machine is logged out. */
647
+ function accountsCache(ctx) {
648
+ let workspace;
649
+ try {
650
+ workspace = loadConfig(ctx.home)?.account ?? null;
651
+ }
652
+ catch {
653
+ workspace = null;
654
+ }
655
+ const cached = workspace === null ? null : readCodexAccountsCache(ctx.home);
656
+ return cached !== null && cached.workspace === workspace ? cached : null;
657
+ }
658
+ /** Why a named home cannot take the exporter, or null when it can: it is logged in to one of the
659
+ * workspace's coding accounts and is not the default home the exporter comes from. */
660
+ function homeRefusal(ctx, home) {
661
+ if (resolve(home) === resolve(defaultCodexHome(ctx)))
662
+ return "it is the default Codex home, which the exporter is copied from";
663
+ const slot = resolveSlot(readCodexAccountId(home), accountsCache(ctx));
664
+ if (slot === NO_LOGIN_SLOT)
665
+ return "there is no Codex login in it";
666
+ if (slot === UNREGISTERED_SLOT)
667
+ return "its Codex login is not one of this workspace's coding accounts (run catalyst codex-env --refresh if it was added just now)";
668
+ return null;
669
+ }
670
+ /** A home's config.toml as it is now: its text and mode, or why it cannot be changed. */
671
+ function readHomeConfig(target) {
672
+ let stat;
673
+ try {
674
+ stat = lstatSync(target);
675
+ }
676
+ catch {
677
+ return { text: "", mode: 0o600, existed: false };
678
+ }
679
+ if (stat.isSymbolicLink())
680
+ return { why: "its config.toml is a link, which setup does not follow" };
681
+ if (!stat.isFile())
682
+ return { why: "its config.toml is not a regular file" };
683
+ try {
684
+ return { text: readFileSync(target, "utf8"), mode: stat.mode & 0o7777, existed: true };
685
+ }
686
+ catch (err) {
687
+ return { why: `its config.toml could not be read (${message(err)})` };
688
+ }
689
+ }
690
+ /** CTC-7028: each named Codex home gets the default home's exporter, after a copy of its config.toml
691
+ * is kept beside it. Header values are never printed. */
692
+ function exportFromHomes(ctx, homes, ops, dryRun, result) {
693
+ if (homes.length === 0)
694
+ return;
695
+ const sourcePath = join(defaultCodexHome(ctx), "config.toml");
696
+ const source = readOr(sourcePath);
697
+ const block = source === null ? null : otelTables(source);
698
+ // The copy carries the collector credential, so the target is never left more readable than the
699
+ // file it came from.
700
+ let sourceMode = 0o600;
701
+ try {
702
+ sourceMode = statSync(sourcePath).mode & 0o777;
703
+ }
704
+ catch {
705
+ // no source means no block, and nothing is written
706
+ }
707
+ for (const raw of homes) {
708
+ const home = resolve(raw);
709
+ const target = join(home, "config.toml");
710
+ const name = tilde(target, ctx.home);
711
+ const skip = (why) => {
712
+ result.homes.push({ home, state: "skipped", why });
713
+ result.lines.push(`Left ${tilde(home, ctx.home)} as it is: ${why}.`);
714
+ };
715
+ const refusal = homeRefusal(ctx, home);
716
+ if (refusal !== null) {
717
+ skip(refusal);
718
+ continue;
719
+ }
720
+ const config = readHomeConfig(target);
721
+ if ("why" in config) {
722
+ skip(config.why);
723
+ continue;
724
+ }
725
+ if (hasOtel(config.text)) {
726
+ result.homes.push({ home, state: "unchanged" });
727
+ result.lines.push(`${name} already has a telemetry exporter (unchanged).`);
728
+ continue;
729
+ }
730
+ if (block === null) {
731
+ skip(`${tilde(sourcePath, ctx.home)} has no [otel] exporter to copy`);
732
+ continue;
733
+ }
734
+ const backup = `${target}${RC_BACKUP_SUFFIX}`;
735
+ if (dryRun) {
736
+ result.homes.push({ home, state: "would_add" });
737
+ result.lines.push(config.existed
738
+ ? `Would add the telemetry exporter from ${tilde(sourcePath, ctx.home)} to ${name}, after keeping a copy of the file as ${tilde(backup, ctx.home)}${existsSync(backup) ? " (an earlier copy is already there and would be kept)" : ""}. Values other than the endpoint, protocol and environment are shown as <hidden>:`
739
+ : `Would create ${name} with the telemetry exporter from ${tilde(sourcePath, ctx.home)}. Values other than the endpoint, protocol and environment are shown as <hidden>:`, ...maskedOtel(block));
740
+ continue;
741
+ }
742
+ try {
743
+ if (config.existed) {
744
+ if (existsSync(backup))
745
+ result.backups.push({ path: target, backup, kept: "earlier" });
746
+ else {
747
+ writeRcAtomic(backup, readFileSync(target), config.mode, ops);
748
+ result.backups.push({ path: target, backup, kept: "new" });
749
+ }
750
+ }
751
+ writeRcAtomic(target, withOtel(config.text, block), config.mode & sourceMode, ops);
752
+ }
753
+ catch (err) {
754
+ result.ok = false;
755
+ skip(`its config.toml could not be written (${message(err)})`);
756
+ continue;
757
+ }
758
+ result.homes.push({ home, state: "added" });
759
+ const kept = result.backups.find((b) => b.path === target);
760
+ result.lines.push(`Codex launches with ${tilde(home, ctx.home)} send their telemetry: the exporter from ${tilde(sourcePath, ctx.home)} is in ${name}.` +
761
+ (kept === undefined
762
+ ? ""
763
+ : kept.kept === "new"
764
+ ? ` The file as it was is kept at ${tilde(kept.backup, ctx.home)}.`
765
+ : ` An earlier copy of the file is kept at ${tilde(kept.backup, ctx.home)}.`));
766
+ }
767
+ }
644
768
  /** `catalyst setup --only codex-attribution`: the hooks setup adds after a full run, and nothing else.
645
769
  * Before it first changes an existing rc file it keeps a byte-for-byte copy beside it, and an earlier
646
770
  * copy is never overwritten, so the copy is what the file was before any hook. `dryRun` writes
@@ -653,6 +777,7 @@ export function onlyCodexAttribution(ctx, deps = {}, options = {}) {
653
777
  files: [],
654
778
  backups: [],
655
779
  agent: { state: "other_home" },
780
+ homes: [],
656
781
  lines: [],
657
782
  };
658
783
  if (!ownsHome(ctx.home, deps)) {
@@ -725,6 +850,7 @@ export function onlyCodexAttribution(ctx, deps = {}, options = {}) {
725
850
  }
726
851
  if (result.files.length === 0)
727
852
  result.lines.push("No shell rc file got the Codex launch hook: there is no ~/.zshrc or ~/.bashrc, and your login shell is neither zsh nor bash.");
853
+ exportFromHomes(ctx, options.codexHomes ?? [], ops, dryRun, result);
728
854
  const d = resolved(ctx, deps);
729
855
  if (d.platform !== "darwin") {
730
856
  result.agent = { state: "not_macos" };
@@ -771,8 +897,43 @@ export function codexAttributionAfterSetup(args, ctx, deps = {}) {
771
897
  for (const line of setupCodexAttribution(ctx, deps))
772
898
  say(line);
773
899
  }
900
+ /** Takes the copied exporter block out of each named home's config.toml. With nothing edited after
901
+ * setup added it, the file is byte for byte what it was. */
902
+ function removeHomeExporters(ctx, homes, ops) {
903
+ const out = [];
904
+ const lines = [];
905
+ for (const raw of homes) {
906
+ const home = resolve(raw);
907
+ const target = join(home, "config.toml");
908
+ const name = tilde(target, ctx.home);
909
+ const config = readHomeConfig(target);
910
+ if ("why" in config) {
911
+ out.push({ home, state: "skipped", why: config.why });
912
+ lines.push(`Left ${tilde(home, ctx.home)} as it is: ${config.why}.`);
913
+ continue;
914
+ }
915
+ const restored = config.existed ? withoutOtel(config.text) : null;
916
+ if (restored === null) {
917
+ out.push({ home, state: "absent" });
918
+ lines.push(`${name} had no telemetry exporter from setup.`);
919
+ continue;
920
+ }
921
+ try {
922
+ writeRcAtomic(target, restored, config.mode, ops);
923
+ }
924
+ catch (err) {
925
+ const why = `its config.toml could not be written (${message(err)})`;
926
+ out.push({ home, state: "skipped", why });
927
+ lines.push(`Left ${tilde(home, ctx.home)} as it is: ${why}.`);
928
+ continue;
929
+ }
930
+ out.push({ home, state: "removed" });
931
+ lines.push(`Took the copied telemetry exporter out of ${name}; the file is as it was before setup added it.`);
932
+ }
933
+ return { homes: out, lines };
934
+ }
774
935
  /** `catalyst setup --remove-codex-attribution`. Run twice, the second run finds nothing to remove. */
775
- export function removeCodexAttribution(ctx, deps = {}) {
936
+ export function removeCodexAttribution(ctx, deps = {}, codexHomes = []) {
776
937
  const files = shellRcFiles(ctx).flatMap((file) => {
777
938
  try {
778
939
  const outcome = removeShellHook(file.path, ctx.home, fileOps(deps));
@@ -810,12 +971,16 @@ export function removeCodexAttribution(ctx, deps = {}) {
810
971
  lines.push(`Took the coding account and machine name out of ${ENV_NAME} in the launch environment, and kept the attributes you set there yourself. Apps that are already open keep the old value until they restart.`);
811
972
  else if (agent.environment === "failed")
812
973
  lines.push(`Could not change ${ENV_NAME} in the launch environment. Run launchctl getenv ${ENV_NAME} to see it, and launchctl unsetenv ${ENV_NAME} to clear it.`);
974
+ const homes = removeHomeExporters(ctx, codexHomes, fileOps(deps));
975
+ lines.push(...homes.lines);
813
976
  return {
814
977
  ok: files.every((f) => f.state !== "skipped") &&
978
+ homes.homes.every((h) => h.state !== "skipped") &&
815
979
  agent.agent !== "failed" &&
816
980
  agent.environment !== "failed",
817
981
  files,
818
982
  agent,
983
+ homes: homes.homes,
819
984
  lines,
820
985
  };
821
986
  }
@@ -0,0 +1,134 @@
1
+ // codex-otel.ts — CTC-7028. A Codex home named with `--codex-home` sends its telemetry.
2
+ //
3
+ // Codex reads its exporter from the `[otel]` tables of its home's config.toml. A second home (a second
4
+ // Codex login on the same machine) starts with none, so its launches send nothing, and the coding
5
+ // account `catalyst setup --only codex-attribution` resolves for it never reaches a row. Setup copies
6
+ // the default home's `[otel]` and `[otel.*]` tables, verbatim, into each named home that is logged in
7
+ // to one of the workspace's coding accounts and has no exporter of its own.
8
+ //
9
+ // ⛔ HEADER VALUES CARRY THE COLLECTOR CREDENTIAL. They are copied file to file and never printed,
10
+ // logged or put in argv. TOML can spell a header many ways (a headers table, an inline table, a
11
+ // dotted or quoted key), so a dry run shows only an allow-list of plain values and hides the rest.
12
+ //
13
+ // The copy goes in as one marked block at the end of the file, so `--remove-codex-attribution` can
14
+ // take it out again and leave the file byte for byte as it was.
15
+ export const OTEL_BEGIN = "# >>> catalyst codex-attribution: telemetry exporter copied from the default Codex home >>>";
16
+ /** The same marker when setup also had to end the file's last line before the block. */
17
+ export const OTEL_BEGIN_NEWLINE = `${OTEL_BEGIN} (line break added)`;
18
+ export const OTEL_END = "# <<< catalyst codex-attribution <<<";
19
+ export const HIDDEN = "<hidden>";
20
+ const TABLE = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/;
21
+ const OTEL_NAME = /^"?otel"?(\.|$)/;
22
+ const KEY = /^(\s*[^=\s][^=]*?)\s*=\s*(.*)$/;
23
+ /** The only values a dry run shows. Everything else under otel (headers in any spelling, inline
24
+ * tables, unknown keys) can carry the collector credential, so it is shown as <hidden>. */
25
+ const SHOWN = new Set(["endpoint", "protocol", "environment", "exporter", "log_user_prompt"]);
26
+ function lines(text) {
27
+ return text.split("\n");
28
+ }
29
+ /** How far a value opens brackets, counting none inside quoted strings or after a comment. */
30
+ function depth(value) {
31
+ const bare = value.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "").replace(/#.*$/, "");
32
+ let d = 0;
33
+ for (const ch of bare)
34
+ d += ch === "[" || ch === "{" ? 1 : ch === "]" || ch === "}" ? -1 : 0;
35
+ return d;
36
+ }
37
+ /** Each line of a config.toml, read well enough to tell a table header from a value line. */
38
+ function classify(text) {
39
+ let open = 0;
40
+ return lines(text).map((line) => {
41
+ if (open > 0) {
42
+ open += depth(line);
43
+ return { kind: "continued" };
44
+ }
45
+ const table = TABLE.exec(line);
46
+ if (table)
47
+ return { kind: "table", name: table[1] ?? "" };
48
+ const key = KEY.exec(line);
49
+ if (key && !line.trim().startsWith("#")) {
50
+ open = Math.max(0, depth(key[2] ?? ""));
51
+ return { kind: "key", key: key[1] ?? "", value: key[2] ?? "" };
52
+ }
53
+ return { kind: "other" };
54
+ });
55
+ }
56
+ /** The `[otel]` and `[otel.*]` tables of a config.toml, each with the lines up to the next table, in
57
+ * file order; null when there is none. */
58
+ export function otelTables(text) {
59
+ const out = [];
60
+ const all = lines(text);
61
+ let inOtel = false;
62
+ classify(text).forEach((line, i) => {
63
+ if (line.kind === "table")
64
+ inOtel = OTEL_NAME.test(line.name);
65
+ if (inOtel)
66
+ out.push(all[i] ?? "");
67
+ });
68
+ while (out.length > 0 && out[out.length - 1]?.trim() === "")
69
+ out.pop();
70
+ return out.length === 0 ? null : `${out.join("\n")}\n`;
71
+ }
72
+ /** True when the file already configures any exporter: an otel table, an `otel` key, or our block. */
73
+ export function hasOtel(text) {
74
+ let inTable = false;
75
+ const all = lines(text);
76
+ return classify(text).some((line, i) => {
77
+ if (all[i] === OTEL_BEGIN || all[i] === OTEL_BEGIN_NEWLINE)
78
+ return true;
79
+ if (line.kind === "table") {
80
+ inTable = true;
81
+ return OTEL_NAME.test(line.name);
82
+ }
83
+ return !inTable && line.kind === "key" && /^\s*"?otel"?\s*(\.|$)/.test(line.key);
84
+ });
85
+ }
86
+ /** The block for display. Table headers, comments and the values of the keys in SHOWN are as they
87
+ * are; every other value, any value under a headers table, and every continued line is <hidden>. */
88
+ export function maskedOtel(block) {
89
+ let headers = false;
90
+ const all = lines(block.replace(/\n$/, ""));
91
+ return classify(all.join("\n")).map((line, i) => {
92
+ const raw = all[i] ?? "";
93
+ if (line.kind === "table") {
94
+ headers = /(^|\.)\s*"?headers"?$/.test(line.name);
95
+ return raw;
96
+ }
97
+ if (line.kind === "continued")
98
+ return ` ${HIDDEN}`;
99
+ if (line.kind === "other")
100
+ return raw.trim() === "" || raw.trim().startsWith("#") ? raw : HIDDEN;
101
+ const name = line.key.trim().replace(/^"|"$/g, "");
102
+ const plain = !/[{[]/.test(line.value);
103
+ return !headers && plain && SHOWN.has(name) ? raw : `${line.key} = ${HIDDEN}`;
104
+ });
105
+ }
106
+ /** The file with the block appended. A file that does not end its last line gets one line break first,
107
+ * and the marker records it, so taking the block out restores the file byte for byte. */
108
+ export function withOtel(original, block) {
109
+ const open = original === "" || original.endsWith("\n");
110
+ return `${original}${open ? "" : "\n"}${open ? OTEL_BEGIN : OTEL_BEGIN_NEWLINE}\n${block}${OTEL_END}\n`;
111
+ }
112
+ /** The file without our block, or null when it has none (or only half of one, which is left alone). */
113
+ export function withoutOtel(text) {
114
+ let start = lineAt(text, OTEL_BEGIN);
115
+ let addedBreak = false;
116
+ if (start < 0) {
117
+ start = lineAt(text, OTEL_BEGIN_NEWLINE);
118
+ addedBreak = true;
119
+ }
120
+ if (start < 0)
121
+ return null;
122
+ const endAt = text.indexOf(`\n${OTEL_END}\n`, start);
123
+ if (endAt < 0)
124
+ return null;
125
+ const before = text.slice(0, start);
126
+ return (addedBreak && before.endsWith("\n") ? before.slice(0, -1) : before) + text.slice(endAt + OTEL_END.length + 2);
127
+ }
128
+ /** Where the line that is exactly `line` starts, or -1. */
129
+ function lineAt(text, line) {
130
+ if (text.startsWith(`${line}\n`))
131
+ return 0;
132
+ const at = text.indexOf(`\n${line}\n`);
133
+ return at < 0 ? -1 : at + 1;
134
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { CLAUDE_PATH_ENV } from "./update-plugin.js";
5
5
  // The schedule that runs `catalyst runner update --auto` every 15 minutes (CTC-4646): a LaunchAgent
@@ -35,7 +35,7 @@ export function systemdUnitPaths(home) {
35
35
  return { service: join(base, `${AUTO_UPDATE_UNIT}.service`), timer: join(base, `${AUTO_UPDATE_UNIT}.timer`) };
36
36
  }
37
37
  export function launchAgentPlist(deps, dir) {
38
- const args = [deps.cli, "runner", "update", "--auto", "--dir", dir];
38
+ const args = [...(deps.node ? [deps.node] : []), deps.cli, "runner", "update", "--auto", "--dir", dir];
39
39
  return [
40
40
  '<?xml version="1.0" encoding="UTF-8"?>',
41
41
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -129,6 +129,23 @@ export function autoUpdateStatus(deps) {
129
129
  }
130
130
  return { state: "unsupported", detail: `platform_${platform}` };
131
131
  }
132
+ /** CTC-7030: the node a Mac's thoughts custody was set up with, when that node is still there. The
133
+ * custody records it by path and hash beside the runner directory, and every update re-attests the
134
+ * custody under the node that runs it, refusing any other. A CLI installed under a different node
135
+ * (the daily job's own prefix) must therefore still run under this one. */
136
+ export function custodyNode(dir) {
137
+ try {
138
+ const runtime = JSON.parse(readFileSync(join(dirname(dir), "darwin-thoughts-custody", "runtime.json"), "utf8"));
139
+ const node = runtime.nodeExecutable;
140
+ if (typeof node !== "string" || !node.startsWith("/"))
141
+ return null;
142
+ accessSync(node, constants.X_OK);
143
+ return node;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
132
149
  /** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up. */
133
150
  export function enableAutoUpdate(deps, dir) {
134
151
  const platform = deps.platform ?? process.platform;
@@ -136,7 +153,7 @@ export function enableAutoUpdate(deps, dir) {
136
153
  if (platform === "darwin") {
137
154
  const uid = deps.uid ?? process.getuid?.() ?? 0;
138
155
  const path = launchAgentPath(deps.home);
139
- const text = launchAgentPlist(deps, dir);
156
+ const text = launchAgentPlist({ ...deps, node: deps.node === undefined ? custodyNode(dir) : deps.node }, dir);
140
157
  const unchanged = existsSync(path) && readFileSync(path, "utf8") === text;
141
158
  const domain = `gui/${uid}`;
142
159
  const loaded = run("launchctl", ["print", `${domain}/${AUTO_UPDATE_LABEL}`]).status === 0;
@@ -1,7 +1,9 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
1
3
  import { isAbsolute, resolve } from "node:path";
2
4
  import { flagBool, flagString, positionals } from "./args.js";
3
5
  import { CliError, UsageError } from "./errors.js";
4
- import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
6
+ import { autoUpdateStatus, custodyNode, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
5
7
  import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, } from "./runner-update.js";
6
8
  import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
7
9
  import { runUpdate, sharedReleaseRead, updateReportJson, updateReportLines } from "./update.js";
@@ -9,6 +11,32 @@ import { effectivePolicy, readUpdatePolicy, setComponentPolicy } from "./update-
9
11
  import { resolveClaude } from "./update-plugin.js";
10
12
  import { defaultWindows, windowBounds } from "./update-windows.js";
11
13
  import { cmdRunnerLifecycle, RUNNER_LIFECYCLE_ACTIONS } from "./runner.js";
14
+ /** Set on the re-run, so it never re-runs itself again. */
15
+ export const CUSTODY_RERUN_ENV = "CATALYST_CUSTODY_NODE_RERUN";
16
+ const real = (path) => {
17
+ try {
18
+ return realpathSync(path);
19
+ }
20
+ catch {
21
+ return path;
22
+ }
23
+ };
24
+ /** CTC-7046: a Mac's thoughts custody refuses any update run under another node than the one it was
25
+ * set up with. An update run under another node, by an agent written before 0.16.37 or by a person's
26
+ * shell, re-runs itself under the custody's node and answers with that run's exit code. This works
27
+ * whatever wrote the agent, and never reloads the agent mid-run. Null: carry on in this process. */
28
+ function custodyRerun(ctx, deps, dir) {
29
+ if ((deps.platform ?? process.platform) !== "darwin" || ctx.env[CUSTODY_RERUN_ENV] === "1")
30
+ return null;
31
+ const node = (deps.node ?? custodyNode)(dir);
32
+ if (node === null || real(node) === real(deps.execPath ?? process.execPath))
33
+ return null;
34
+ const [script, ...rest] = deps.argv ?? process.argv.slice(1);
35
+ if (script === undefined)
36
+ return null;
37
+ const run = deps.run ?? ((command, args, env) => spawnSync(command, [...args], { stdio: "inherit", env }).status ?? 1);
38
+ return run(node, [script, ...rest], { ...ctx.env, [CUSTODY_RERUN_ENV]: "1" });
39
+ }
12
40
  const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
13
41
  const UPDATE_FLAGS = ["check", "auto", "dir", "fleet"];
14
42
  function schedulerDeps(ctx, deps) {
@@ -76,6 +104,11 @@ export async function cmdRunner(args, ctx, deps = {}) {
76
104
  const fleet = flagBool(args, "fleet");
77
105
  if (fleet && dirFlag === undefined)
78
106
  throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
107
+ if (!fleet && !check) {
108
+ const rerun = custodyRerun(ctx, deps.custody ?? {}, dir());
109
+ if (rerun !== null)
110
+ return rerun;
111
+ }
79
112
  // One desired-release read serves the image check and the component report (CTC-6708).
80
113
  const engine = sharedReleaseRead(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }));
81
114
  // The 15-minute schedule runs `runner update --auto`. After the images it carries on with every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.36",
3
+ "version": "0.16.38",
4
4
  "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
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.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # Unstick
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # What needs me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # What this repository needs
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use for "what's happening?", "what machines do we have?", "how much capacity do we have?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.36 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.38 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10