@catalyst-cloud/cli 0.16.37 → 0.16.39

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.39
4
+
5
+ `catalyst query pulls` now sends `--state`, `--repo` and `--draft` to Catalyst, so `--state open --all` reads only the workspace's open pull requests instead of paging through every pull request it has ever had. `--state` takes `open`, `closed` or `merged`, and `--draft` takes `draft` or `ready`. Any other value is refused before a request is made. `open` and `closed` leave out merged pull requests, and the repository and state match in any letter case. Against an older Catalyst that ignores these filters, the CLI applies them to the rows it gets back, so the result is the same. A filtered read of the local replica now checks every pull request it holds, so an older pull request linked to a ticket is still found.
6
+
7
+ The `catalyst-linear` skill reads the project's label definitions before it chooses a ticket's work type and component.
8
+
9
+ ## 0.16.38
10
+
11
+ 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.
12
+
13
+ `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.
14
+
3
15
  ## 0.16.37
4
16
 
5
17
  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.
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
 
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,
@@ -132,7 +137,12 @@ export const FLAG_TABLES = {
132
137
  query: {
133
138
  team: { value: true, help: "team key filter" },
134
139
  project: { value: true, help: "project id filter" },
135
- state: { value: true, help: "state name filter" },
140
+ state: {
141
+ value: true,
142
+ help: "issues: state name filter; pulls: open, closed or merged",
143
+ },
144
+ repo: { value: true, help: "pulls: only this repository (owner/name)" },
145
+ draft: { value: true, help: "pulls: draft or ready" },
136
146
  limit: {
137
147
  value: true,
138
148
  help: "max rows (default 50); one page only unless --all",
@@ -607,13 +617,13 @@ export const VERB_USAGE = {
607
617
  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
618
  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
619
  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]",
620
+ 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
621
  install: "install [--skills-dir <dir>] [--force]",
612
622
  status: "status",
613
623
  notice: "notice",
614
624
  me: "me [--json]",
615
625
  contract: "contract [--refresh] [--path <a.b.c>] [--json]",
616
- query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--all] [--source replica|api] [--json]",
626
+ query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--repo O/N] [--draft draft|ready] [--ticket T] [--limit N] [--all] [--source replica|api] [--json]",
617
627
  replica: 'replica <start [--detach]|stop|status [--probe] [--json]|sql "<select>"|schema [table]> [--db <path>]',
618
628
  runtime: "runtime <status [--json]|install|path|uninstall>",
619
629
  events: "events <tail|wait-for|query|status [--probe] [--json]> [--type NAME] [--ticket CTC-N] [--repo O/N] [--ref O/N:BRANCH] [--pr O/N#N] [--session T/PHASE#N] [--to me] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
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
+ }
package/dist/query.js CHANGED
@@ -68,7 +68,7 @@ export async function cmdQuery(args, ctx, deps = {}) {
68
68
  status.verdict === "not-configured") {
69
69
  throw new UsageError(`--source replica but the replica is ${status?.verdict ?? "unavailable"}`);
70
70
  }
71
- result = await fromReplica(ctx, cfg, sub, rest, filters, deps);
71
+ result = await fromReplica(ctx, cfg, sub, rest, filters, deps, args);
72
72
  }
73
73
  else {
74
74
  result = await fromApi(ctx, cfg, sub, rest, filters, args);
@@ -80,7 +80,7 @@ export async function cmdQuery(args, ctx, deps = {}) {
80
80
  printResult(ctx, args, sub, result);
81
81
  return 0;
82
82
  }
83
- async function fromReplica(ctx, cfg, sub, rest, f, deps) {
83
+ async function fromReplica(ctx, cfg, sub, rest, f, deps, args) {
84
84
  const sdk = await loadSdk();
85
85
  const dbPath = replicaDbPath(cfg, ctx.home, ctx.env);
86
86
  const engine = await engineFor(sdk, dbPath, ctx, {
@@ -100,8 +100,24 @@ async function fromReplica(ctx, cfg, sub, rest, f, deps) {
100
100
  }
101
101
  case "issue":
102
102
  return replica.issue(needArg(rest, "issue <identifier>"));
103
- case "pulls":
104
- return replica.pulls({ limit: f.limit });
103
+ case "pulls": {
104
+ const scope = pullScope(args);
105
+ if (!(scope.state || scope.repo || scope.draft || scope.ticket))
106
+ return replica.pulls({ limit: f.limit });
107
+ // CTC-7158 — the replica is local, so a narrowed read scans every row rather than only the
108
+ // newest window: an older pull request linked to the ticket is still found.
109
+ const kept = [];
110
+ for (let offset = 0;; offset += REPLICA_PULL_PAGE) {
111
+ const page = replica.pulls({
112
+ limit: REPLICA_PULL_PAGE,
113
+ offset,
114
+ });
115
+ kept.push(...page.filter((row) => pullMatches(row, scope)));
116
+ if (page.length < REPLICA_PULL_PAGE || kept.length >= f.limit)
117
+ break;
118
+ }
119
+ return kept.slice(0, f.limit);
120
+ }
105
121
  case "projects":
106
122
  return applyFilters(replica.projects({
107
123
  limit: Math.max(f.limit, 200),
@@ -147,16 +163,23 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
147
163
  return res.status === 404 ? null : res.body;
148
164
  }
149
165
  case "pulls": {
150
- const ticket = flagString(args, "ticket");
166
+ const scope = pullScope(args);
167
+ if (scope.ticket) {
168
+ const rows = await ticketPulls(ctx, api, scope);
169
+ return f.all ? rows : rows.slice(0, f.limit);
170
+ }
171
+ // CTC-7158 — the facets go to the cloud, so `--state open --all` reads the open pull requests
172
+ // instead of paging every one the workspace has ever had.
173
+ const query = { state: scope.state, repo: scope.repo, draft: scope.draft };
151
174
  if (f.all) {
152
- const { rows } = await fetchAllPages(api, "/api/v1/pulls", { ticket });
153
- return rows;
175
+ const { rows } = await fetchAllPages(api, "/api/v1/pulls", query);
176
+ return keepPullScope(ctx, rows, scope);
154
177
  }
155
178
  const page = await fetchPage(api, "/api/v1/pulls", {
179
+ ...query,
156
180
  limit: f.limit,
157
- ticket,
158
181
  });
159
- const rows = page.rows.slice(0, f.limit);
182
+ const rows = keepPullScope(ctx, page.rows, scope).slice(0, f.limit);
160
183
  const notice = truncationNotice(page);
161
184
  if (notice)
162
185
  ctx.stderr(notice);
@@ -264,6 +287,78 @@ function requireIssueScope(scope, f) {
264
287
  throw new CliError(`the cloud did not apply ${flag} (${scope === null ? "it sent no x-mirror-scope header" : `it applied: ${scope.join(", ") || "nothing"}`}), so the page is not that scope; nothing printed. Read it with --source replica, or drop ${flag}`, "filter-not-applied", 1);
265
288
  }
266
289
  }
290
+ const REPLICA_PULL_PAGE = 500;
291
+ const PULL_STATES = ["open", "closed", "merged"];
292
+ const PULL_DRAFTS = ["draft", "ready"];
293
+ /** CTC-7158 — the cloud reads an unknown `?state=` or `?draft=` as no narrowing, so a typo would
294
+ * page every pull request. Refuse it here instead. */
295
+ function pullScope(args) {
296
+ const state = trimmedFlag(args, "state")?.toLowerCase();
297
+ if (state !== undefined && !PULL_STATES.includes(state))
298
+ throw new UsageError(`--state for pulls is one of ${PULL_STATES.join(", ")} (got "${state}")`);
299
+ const draft = trimmedFlag(args, "draft")?.toLowerCase();
300
+ if (draft !== undefined && !PULL_DRAFTS.includes(draft))
301
+ throw new UsageError(`--draft is one of ${PULL_DRAFTS.join(", ")} (got "${draft}")`);
302
+ return {
303
+ state: state,
304
+ repo: trimmedFlag(args, "repo"),
305
+ draft: draft,
306
+ ticket: trimmedFlag(args, "ticket"),
307
+ };
308
+ }
309
+ const truthy = (v) => v === true || v === 1 || v === "1" || v === "true";
310
+ /** The cloud's facet rules (the read model's pull scope): `open` and `closed` leave out merged pull
311
+ * requests, `merged` is the merged flag alone, `ready` is not a draft, and state and repo match
312
+ * without case. */
313
+ function pullMatches(row, s) {
314
+ const merged = truthy(row.merged);
315
+ const state = String(row.state ?? "").toLowerCase();
316
+ if (s.state === "open" && (state !== "open" || merged))
317
+ return false;
318
+ if (s.state === "merged" && !merged)
319
+ return false;
320
+ if (s.state === "closed" && (state !== "closed" || merged))
321
+ return false;
322
+ if (s.draft === "draft" && !truthy(row.draft))
323
+ return false;
324
+ if (s.draft === "ready" && truthy(row.draft))
325
+ return false;
326
+ if (s.repo && !sameName(row.repo_id, s.repo))
327
+ return false;
328
+ if (s.ticket && "linear_issue_identifier" in row && !sameName(row.linear_issue_identifier, s.ticket))
329
+ return false;
330
+ return true;
331
+ }
332
+ /** A cloud that predates a facet returns rows outside it. Drop them and say so, so the answer is
333
+ * still the asked scope. */
334
+ function keepPullScope(ctx, rows, s) {
335
+ const kept = rows.filter((row) => pullMatches(row, s));
336
+ if (kept.length < rows.length)
337
+ ctx.stderr(`the cloud returned ${rows.length - kept.length} pull requests outside the asked scope; dropped them here`);
338
+ return kept;
339
+ }
340
+ /** CTC-7158 — `/pulls` has no ticket facet, and an unknown param narrows nothing, so `?ticket=`
341
+ * returned the newest page for any ticket. The ticket's own read names its linked pull requests;
342
+ * read each of those instead. */
343
+ async function ticketPulls(ctx, api, s) {
344
+ const ticket = s.ticket;
345
+ const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}`, { accept: [404] });
346
+ if (res.status === 404)
347
+ throw new CliError(`--ticket ${ticket}: no such ticket`, "not-found", 1);
348
+ const rows = [];
349
+ for (const link of res.body.linked_pulls ?? []) {
350
+ if (!link.node_id)
351
+ continue;
352
+ const pr = await api.getJson(`/api/v1/pulls/${encodeURIComponent(link.node_id)}`, { accept: [404] });
353
+ if (pr.status === 404) {
354
+ ctx.stderr(`--ticket ${ticket}: linked pull request ${link.node_id} is not in the mirror`);
355
+ continue;
356
+ }
357
+ if (pullMatches(pr.body, { ...s, ticket: undefined }))
358
+ rows.push(pr.body);
359
+ }
360
+ return rows;
361
+ }
267
362
  function trimmedFlag(args, name) {
268
363
  const value = flagString(args, name)?.trim();
269
364
  return value === "" ? undefined : value;
@@ -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.37",
3
+ "version": "0.16.39",
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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -5,6 +5,7 @@ Cloud reads are the default on every machine. They need no local writer and no l
5
5
  | question | command |
6
6
  | -- | -- |
7
7
  | Which pull requests name this ticket? | `catalyst query pulls --ticket <KEY-123>` |
8
+ | Which pull requests are open, in one repository or across them all? | `catalyst query pulls --state open --all` (add `--repo <owner/name>` or `--draft ready`). The cloud filters, so this reads only open ones. Never page every pull request with `--all` and filter it yourself. |
8
9
  | What state is the PR in: draft, merged, conflict, auto-merge? | `node scripts/read-pr.mjs <KEY-123>` |
9
10
  | Which checks are red or still running at the head? | `node scripts/read-pr.mjs <KEY-123>` |
10
11
  | Is it mergeable under this repository's policy? | `node scripts/is-it-mergeable.mjs <KEY-123>`, which also reads `catalyst contract --path merge` |
@@ -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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -30,6 +30,7 @@ Exit codes: 0 done, 1 not found or a usage error, 2 this machine is not connecte
30
30
  | "what did Catalyst write on this ticket?", a comment shape, a label, the marker, the 👀 | `references/what-a-ticket-accumulates.md` |
31
31
  | before any read you will report on; "is this current?"; citing; searching | `references/reading-a-ticket.md` |
32
32
  | before any comment, move, label or new ticket; identity, budget, slots, ids | `references/writing-to-linear.md` |
33
+ | proposing work-type or component labels for creation, triage or backfill | `references/ticket-classification.md`; then the project's `.agents/references/ticket-labels.json` if present |
33
34
  | which command answers a ticket question from the cloud: stage, last phase, PR, what changed | `references/reading-from-the-cloud.md` |
34
35
  | local SQL, only when `catalyst replica status --json` reports `configured: true` | `references/local-replica.md` |
35
36
 
@@ -0,0 +1,11 @@
1
+ # Ticket classification
2
+
3
+ Use this reference when proposing work-type or component labels while creating, triaging, or backfilling tickets. It describes the process; a project's `.agents/references/ticket-labels.json`, when present, owns its vocabulary and boundaries. Read that file from the project checkout before classifying. Do not copy one project's catalog to another team.
4
+
5
+ 1. Read the ticket's description, acceptance criteria, and current labels. A title or repository route alone is not enough to settle a component. Treat linked implementation paths as supporting evidence for a retrospective review, not as proof of the original request.
6
+ 2. Decide whether the record requests delivery or asks for a decision, approval, information, or action. Choose one type from the project's catalog when the evidence supports it. A project may define `ask` as a type for the latter records. Keep that type separate from the workflow marker that routes asks, and from the implementation ticket an ask may block. A bug requires evidence of an existing promised behavior that fails; a new capability is a feature; a new guard is test work, while a broken existing CI contract is a bug. Documentation explains behavior; maintenance publishes or configures it; refactoring preserves it.
7
+ 3. Select zero or more components whose behavior the requested work materially changes. Allow more than one for independent affected areas. Abstain on an unclear boundary; a path, a generic word, or a test mention alone does not establish ownership. Keep project and initiative relationships, ask/control state, and repository routing separate from these labels.
8
+ 4. Record the evidence, uncertainty, and any alternative before a backfill. Apply recommendations only within the authorized scope and under a reviewed classification policy. Preserve the ticket's existing work type unless a reviewed correction calls for replacement. Add supported components without removing unrelated labels. Create a label only when that scope and policy authorize it.
9
+ 5. Before a write, use `writing-to-linear.md` to resolve the live label name, scope, ID or ambiguity through current account access. The project file is policy, not an ID cache. Read back the ticket after a reviewed write.
10
+
11
+ For cleanup of case-only duplicates, inventory active and archived labels and issues. Check synchronization with linked systems, test a disposable canary, and read back both systems after a delay. A write that appears to remove a duplicate may be reversed by synchronization. Stop broad cleanup until the canary has a stable result.
@@ -30,6 +30,8 @@ Every write spends one unit of the per-host daily budget (`thresholds.hostDailyW
30
30
 
31
31
  `--label`, `--add` and `--remove` take a label's name or its UUID. A UUID is sent as a label id; every other value is sent as a name, and the proxy resolves it in the ticket's team. When the contract lists one of Catalyst's own labels (the ask marker, the hold label, the release label) as absent from the workspace, the CLI refuses rather than inventing it.
32
32
 
33
+ For work-type and component choices, load [ticket classification](ticket-classification.md) and the project's `.agents/references/ticket-labels.json` when present. Those files define recommendations, not live IDs. Verify the current team's label scope and the ticket's existing memberships before changing any classification label. A case-only duplicate needs a synchronization check, a disposable canary and delayed readback in both systems before wider cleanup. Include archived labels and issues in the inventory.
34
+
33
35
  Two labels are a human's, not yours: the release label (which frees a ticket the shape detector held) is applied by a human; the hold labels on a pull request are `catalyst-github`'s subject.
34
36
 
35
37
  ## A new ticket takes the team key
@@ -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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — 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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — 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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — 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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — 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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — 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.37 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10