@catalyst-cloud/catalyst-skills 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +13 -10
  3. package/dist/args.js +18 -0
  4. package/dist/cli.js +10 -1
  5. package/dist/events.js +106 -0
  6. package/dist/execution.js +54 -8
  7. package/dist/release.js +142 -0
  8. package/dist/replica.js +7 -1
  9. package/dist/transport.js +5 -2
  10. package/package.json +5 -4
  11. package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
  12. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  13. package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
  14. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  15. package/skills/catalyst-setup/SKILL.md +1 -1
  16. package/skills/catalyst-setup/references/what-each-check-means.md +5 -1
  17. package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
  18. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  19. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  20. package/skills/connect-me/scripts/lib/cli.mjs +3 -2
  21. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  22. package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
  23. package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
  24. package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
  25. package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
  26. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  27. package/skills/run-this-project/SKILL.md +1 -1
  28. package/skills/run-this-project/references/making-work-ready.md +1 -1
  29. package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
  30. package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
  31. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  32. package/skills/unstick/SKILL.md +41 -0
  33. package/skills/unstick/agents/openai.yaml +6 -0
  34. package/skills/unstick/agents/portability.yaml +5 -0
  35. package/skills/unstick/references/playbook.md +51 -0
  36. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  37. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  38. package/skills/unstick/scripts/unstick.mjs +57 -0
  39. package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
  40. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  41. package/skills/whats-happening/SKILL.md +1 -1
  42. package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
  43. package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
  44. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/catalyst-skills",
3
- "version": "0.4.0",
4
- "description": "Customer skill bundle for Catalyst Cloud: eight skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, catalyst-setup, catalyst-linear, catalyst-github, how-catalyst-works, connect-me) plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command.",
3
+ "version": "0.5.0",
4
+ "description": "Customer skill bundle for Catalyst Cloud: nine skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, unstick, catalyst-setup, catalyst-linear, catalyst-github, how-catalyst-works, connect-me) plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -38,10 +38,11 @@
38
38
  "prepare": "tsc -p tsconfig.build.json",
39
39
  "test": "vitest run",
40
40
  "test:coverage": "vitest run --coverage",
41
- "version:sync": "node scripts/sync-plugin-version.mjs"
41
+ "version:sync": "node scripts/sync-plugin-version.mjs",
42
+ "skill-lib:sync": "node scripts/sync-skill-lib.mjs"
42
43
  },
43
44
  "dependencies": {
44
- "@catalyst-cloud/sdk": "^0.9.0"
45
+ "@catalyst-cloud/sdk": "^0.10.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^26.3.0",
@@ -8,9 +8,10 @@ import { spawnSync } from "node:child_process";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
11
12
 
12
13
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
13
- export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
14
+ export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
14
15
 
15
16
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
16
17
  export function configPath() {
@@ -32,7 +33,7 @@ export function requireCustomerConfig() {
32
33
  console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
33
34
  process.exit(2);
34
35
  }
35
- if (!cfg || typeof cfg !== "object" || typeof cfg.key !== "string" || typeof cfg.baseUrl !== "string") {
36
+ if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
36
37
  console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
37
38
  process.exit(2);
38
39
  }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }
@@ -6,9 +6,10 @@ import { spawnSync } from "node:child_process";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
9
10
 
10
11
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
11
- export const CONNECT_HINT = `CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
12
+ export const CONNECT_HINT = CONNECT_COMMAND;
12
13
 
13
14
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME (used by tests) over HOME. */
14
15
  export function configPath() {
@@ -22,8 +23,8 @@ export function loadConfig() {
22
23
  if (!existsSync(path)) return { ok: false, reason: `no config at ${path}` };
23
24
  try {
24
25
  const cfg = JSON.parse(readFileSync(path, "utf8"));
25
- if (!cfg || typeof cfg.key !== "string" || typeof cfg.account !== "string") {
26
- return { ok: false, reason: `${path} is missing the key or account` };
26
+ if (!hasCredential(cfg) || typeof cfg.account !== "string") {
27
+ return { ok: false, reason: `${path} is missing a key or login, or the account` };
27
28
  }
28
29
  return { ok: true, cfg, path };
29
30
  } catch (err) {
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }
@@ -26,7 +26,7 @@ You answer one question with one verdict and one list. The verdict is READY or N
26
26
  - Report, never repair. No tenant-reachable repair verb exists for a key yet; the settings page is where a tenant owner or admin fixes a mapping, a connection or a label, and you say which one.
27
27
  - End every answer with the verdict and the who-can-click-what list, in that order.
28
28
  - A stale or absent replica is a note, never a failure: every skill reads the API meanwhile and says so. Do not tell the person to start it unless they want local SQL or cheaper repeated reads.
29
- - Not connected (exit 2) means the connect step, not a retry: `CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login`, with the person's own key from Settings → API keys. Never guess a tenant; the key is the only selector.
29
+ - Not connected (exit 2) means the connect step, not a retry: `npx @catalyst-cloud/catalyst-skills login`, which logs the person in keyless in their browser; with a personal key from Settings → API keys instead, prefix it with `CATALYST_CLOUD_TOKEN=<your personal key>`. Never guess a tenant; the login (or the key) is the only selector.
30
30
  - Waiting is not failing. "No write observed", "no delivery observed" and "no host connected" clear themselves the first time the thing happens; say that instead of raising them.
31
31
  - Unknown is not a pass. A check the engine could not run is reported as such, never rounded up.
32
32
  - Never run a check in a loop. If the person wants to know when a waiting check clears, that is the project-running skill's watch.
@@ -37,7 +37,7 @@ Three checks are informational by design (labels, event delivery, host currency)
37
37
  | id | proves | fix |
38
38
  | -- | -- | -- |
39
39
  | `node` | Node 22 or newer, which the SDK's built-in SQLite engine needs | install Node 22+ |
40
- | `config` | this machine is connected: `customer.json` exists and loads | `CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login`; the person mints the key at Settings → API keys |
40
+ | `config` | this machine is connected: `customer.json` exists and loads | `npx @catalyst-cloud/catalyst-skills login` (keyless: the person approves in their browser); or, with a personal key minted at Settings → API keys, the same command prefixed with `CATALYST_CLOUD_TOKEN=<your personal key>` |
41
41
  | `contract` | the tenant contract is cached and its major version is one this bundle accepts | `catalyst-skills contract --refresh`; a version outside the range means update the bundle. A 403 naming an older cloud means the cloud has not yet deployed personal-key access |
42
42
  | `cliPath` | the CLI path recorded at login still exists, so skill scripts can spawn it | re-run login |
43
43
  | `skills` | every skill of this bundle is present where the CLI installed them | `catalyst-skills install` |
@@ -60,3 +60,7 @@ Three checks are informational by design (labels, event delivery, host currency)
60
60
  ## Who can click what
61
61
 
62
62
  The last block `check.mjs` prints groups every failure by the person it needs. Machine fixes name "you", the person at the keyboard. Tenant fixes name the owner or admin roles the contract lists (as Linear user ids, since that is how the cloud knows them), because the settings page that repairs a mapping, a connection or a label is theirs. A check the contract marks as not needing an answer names nobody: it is informational or self-clearing. This skill reports; it never repairs, because no key-reachable repair verb exists yet.
63
+
64
+ ## Setting up one team at a time
65
+
66
+ A team starts receiving work only once its stages are saved, and that is done one team at a time: a tenant owner or admin opens Settings → Linear teams, picks the team and presses Map my stages (or Adopt the Catalyst workflow). No other team's stages or tickets change; only the labels Adopt creates are shared across the workspace. Before saving, the screen lists which of that team's tickets would start and which stay where they are. Once saved, the tickets in the team's dispatch stage start; tickets in other stages that Catalyst never worked on stay where they are. To pilot safely, pick a low-stakes team, move anything in its dispatch stage that should not start back to Backlog, and leave the other teams unmapped. `gitAutomation` in the contract plays no part: nothing reads it.
@@ -8,9 +8,10 @@ import { spawnSync } from "node:child_process";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
11
12
 
12
13
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
13
- export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
14
+ export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
14
15
 
15
16
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
16
17
  export function configPath() {
@@ -32,7 +33,7 @@ export function requireCustomerConfig() {
32
33
  console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
33
34
  process.exit(2);
34
35
  }
35
- if (!cfg || typeof cfg !== "object" || typeof cfg.key !== "string" || typeof cfg.baseUrl !== "string") {
36
+ if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
36
37
  console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
37
38
  process.exit(2);
38
39
  }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }
@@ -14,7 +14,7 @@ const HELP = `Usage: node scripts/replica-status.mjs [--probe] [--json]
14
14
  Exit codes, passed through from catalyst-skills replica status:
15
15
  0 fresh: a live writer, a young heartbeat, a cursor — skills read the replica
16
16
  1 stale: the file exists but the writer is gone or behind — skills read the API and say so
17
- 2 not connected to a tenant — run: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login
17
+ 2 not connected to a tenant — run: npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)
18
18
  3 absent: no replica file — optional; start one with: catalyst-skills replica start --detach`;
19
19
 
20
20
  const args = process.argv.slice(2);
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
13
14
 
14
15
  export const PACKAGE_NAME = "@catalyst-cloud/catalyst-skills";
15
16
  export const NOT_CONFIGURED_EXIT = 2;
@@ -33,7 +34,7 @@ export function loadCustomerConfig() {
33
34
  if (!existsSync(path)) return null;
34
35
  try {
35
36
  const parsed = JSON.parse(readFileSync(path, "utf8"));
36
- if (!parsed || typeof parsed !== "object" || typeof parsed.key !== "string" || typeof parsed.account !== "string") return null;
37
+ if (!hasCredential(parsed) || typeof parsed.account !== "string") return null;
37
38
  return parsed;
38
39
  } catch {
39
40
  return null;
@@ -44,7 +45,7 @@ export function loadCustomerConfig() {
44
45
  export function requireConfigured() {
45
46
  const cfg = loadCustomerConfig();
46
47
  if (cfg) return cfg;
47
- console.error(`not connected: ${configPath()} is missing or unreadable — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE_NAME} login`);
48
+ console.error(`not connected: ${configPath()} is missing or unreadable — run: ${CONNECT_COMMAND}`);
48
49
  process.exit(NOT_CONFIGURED_EXIT);
49
50
  }
50
51
 
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }
@@ -28,7 +28,7 @@ The contract's `teams[].workflowMode` reports which one a team is in:
28
28
  - **mapped**: the team kept its existing stages and a human chose which stage fills each slot.
29
29
  - **mixed**: some slots adopted, some hand-chosen.
30
30
 
31
- `teams[].gitAutomation` is a separate switch, off by default, because enabling it can delete a team's own review automation in Linear; it is never bundled into "adopt recommended".
31
+ `teams[].gitAutomation` is a stored consent for a feature that is not built yet (Catalyst managing a team's Linear git automations); nothing reads it, so `off` never stops work and turning it on would start none. What decides whether a team's tickets start is the mapping above: until dispatch, pr, done and canceled each point at a live stage, Catalyst starts nothing in that team, and `explain` says so by name.
32
32
 
33
33
  ## The state id is the authority; names are display
34
34
 
@@ -66,7 +66,7 @@ No survivor is `no_eligible_account_slot` when any candidate was skipped on capa
66
66
 
67
67
  ## The unknowns (the evaluator fails closed)
68
68
 
69
- When the cloud cannot answer, it says so rather than guessing: `ordering_never_published`, `ordering_stale`, `workflow_mapping_unknown`, `ticket_unknown`, `dependency_snapshot_unknown`, `blocker_unknown`, `label_snapshot_unknown`, `prior_artifact_unknown`, `scope_unknown`, `scope_occupancy_unknown`, `branch_snapshot_unknown`. An unknown is "I could not look", never "it is not there"; report it as inconclusive.
69
+ When the cloud cannot answer, it says so rather than guessing: `ordering_never_published`, `ordering_stale`, `workflow_mapping_unknown`, `ticket_unknown`, `dependency_snapshot_unknown`, `blocker_unknown`, `label_snapshot_unknown`, `prior_artifact_unknown`, `scope_unknown`, `scope_occupancy_unknown`, `branch_snapshot_unknown`. An unknown is "I could not look", never "it is not there"; report it as inconclusive. The exception is a team with no saved stage mapping: `workflow_mapping_unknown` never clears on its own there, `explain` names the missing stages, and the fix is a tenant owner or admin mapping the team.
70
70
 
71
71
  One advisory gates nothing: `human_addressed_unlabeled_ask_suspect` (assigned to a human with no delegate; possibly an unlabelled ask).
72
72
 
@@ -33,12 +33,12 @@ Which parks release themselves:
33
33
 
34
34
  | Park | Releases by |
35
35
  | -- | -- |
36
- | repeated failure | an operator, not a clock |
37
- | remediate round cap reached | an operator, not a clock |
36
+ | repeated failure | a release from the person's own login once its cause is fixed (`catalyst-skills release`), not a clock |
37
+ | remediate round cap reached | the same release, which buys one more round, not a clock |
38
38
  | missing branch | its own budgeted release loop |
39
39
  | rebase conflict | its own budgeted release loop |
40
40
 
41
- Your key cannot release a park; the repair verbs are operator-only. When a ticket is parked, say so, name the failure class the explainer shows, and hand the release to whoever administers the tenant.
41
+ A person's own key releases a park, and every other hold that a retry can fix, in one step: `catalyst-skills release <ticket> --because <what changed>`. The cloud releases every governor holding the ticket or releases nothing and names what a person must do instead (a person's own pull request, a review that will not converge, the lifetime repair budget). It refuses a cause it cannot see change unless the caller says what changed. The `unstick` skill runs that loop. When a ticket is parked, name the failure class the explainer shows and whether its cause is fixed.
42
42
 
43
43
  ## Two holds that are not failures
44
44
 
@@ -53,5 +53,5 @@ Each attempt posts a phase-outcome comment (complete or FAILED, with phase, atte
53
53
 
54
54
  1. Run `node scripts/explain-ticket.mjs <ticket>`; the reason names the layer.
55
55
  2. `retry_backoff` or `routing_unavailable`: wait; it retries itself. Say when.
56
- 3. `cooling_down`, `remediate_parked`, `no_change_hold`, `validate_class_spent`: name what releases it (an operator, a comment, a push) and who can do that.
56
+ 3. `cooling_down`, `remediate_parked`, `no_change_hold`, `validate_class_spent`: name what releases it (a release once the cause is fixed, a comment, a push) and who can do that.
57
57
  4. A system-level cause (provider down, runner image breaker, repo paused) is ONE alert, never a per-ticket escalation.
@@ -6,9 +6,10 @@ import { spawnSync } from "node:child_process";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
9
10
 
10
11
  export const PACKAGE = "@catalyst-cloud/catalyst-skills";
11
- export const CONNECT_HINT = `CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE} login`;
12
+ export const CONNECT_HINT = CONNECT_COMMAND;
12
13
 
13
14
  /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME (used by tests) over HOME. */
14
15
  export function configPath() {
@@ -22,8 +23,8 @@ export function loadConfig() {
22
23
  if (!existsSync(path)) return { ok: false, reason: `no config at ${path}` };
23
24
  try {
24
25
  const cfg = JSON.parse(readFileSync(path, "utf8"));
25
- if (!cfg || typeof cfg.key !== "string" || typeof cfg.account !== "string") {
26
- return { ok: false, reason: `${path} is missing the key or account` };
26
+ if (!hasCredential(cfg) || typeof cfg.account !== "string") {
27
+ return { ok: false, reason: `${path} is missing a key or login, or the account` };
27
28
  }
28
29
  return { ok: true, cfg, path };
29
30
  } catch (err) {
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }
@@ -42,4 +42,4 @@ Every script exits 2 when this machine is not connected (run `catalyst-skills lo
42
42
  - **Escalate inward.** Instrument, then you, then the desk, then the human as an ask filed through `what-needs-me` with what it blocks. Decide the technical calls yourself; take the sane default and record it; a fleet or provider condition is one note, never one ask per ticket.
43
43
  - **Reply where the message arrived,** in-thread, tagged, as the app actor. Never post as the human, never answer someone else's ask. Bookkeeping records take the contract's marker (`catalyst-skills write comment --bookkeeping`).
44
44
  - **One status summary,** kept current after every reaction: in flight, blocked and on whom, closed, next, each line with a ticket id. Where a human decision is pending, the line carries the ask's id.
45
- - **Say what a key cannot see.** A park is released only by an operator, and PR labels and reactions are not mirrored; the CLI says so by name and points at settings. Execution history (`explain --history`) and coding-account status (`accounts`) ARE readable — read them rather than reconstructing them from comments.
45
+ - **Say what a key cannot see.** PR labels and reactions are not mirrored; the CLI says so by name and points at settings. A park or hold is released from the person's own login once its cause is fixed, through the `unstick` skill (`catalyst-skills release`). Execution history (`explain --history`) and coding-account status (`accounts`) ARE readable — read them rather than reconstructing them from comments.
@@ -48,7 +48,7 @@ The card's stage is the fourth, weakest signal: a completed phase moves the card
48
48
 
49
49
  ## Parking, and what it does not do
50
50
 
51
- Parking is the lever that stops the cloud offering more rounds on a ticket: moved out of the dispatch column and the ladder's stages, the ticket is excluded at the next offer. It does not kill a phase that is already running under a lease; that container finishes its phase, posts its outcome, and the next offer finds the card parked. If you park a ticket the cloud has itself parked (three consecutive failures, or the remediate round cap), record why in a bookkeeping note; releasing a cloud park is an operator action, not a card move, so that is an ask for a human with the ticket named as what it blocks.
51
+ Parking is the lever that stops the cloud offering more rounds on a ticket: moved out of the dispatch column and the ladder's stages, the ticket is excluded at the next offer. It does not kill a phase that is already running under a lease; that container finishes its phase, posts its outcome, and the next offer finds the card parked. If you park a ticket the cloud has itself parked (three consecutive failures, or the remediate round cap), record why in a bookkeeping note. Releasing a cloud park is not a card move: once its cause is fixed, the `unstick` skill releases it with `catalyst-skills release <ticket>`, and only a refusal that names a person's action becomes an ask.
52
52
 
53
53
  Un-parking is the same dispatch move again. The counted attempts and rounds do not reset when a card comes back; the contract's thresholds say how many remain.
54
54
 
@@ -26,7 +26,7 @@ Run `catalyst-skills explain <ticket>` first, and let its reason pick the row:
26
26
  | blocked | a live blocks relation | chase the blocker: is it dispatchable, is it an ask nobody answered, is it done but still open |
27
27
  | ask ticket, ask shape suspected | the ticket is a question | route it to `what-needs-me`; if it is really work, a human applies the release label |
28
28
  | not at dispatch stage | somebody moved the card, or it never entered | read the card's history; re-dispatch with `make-ready.mjs` if it should run |
29
- | cooling down, remediate parked | the cloud parked it after repeated failure or the round cap | read the last outcome card for the class; if the fix is a decision, file an ask; releasing the park is an operator action |
29
+ | phase parked, cooling down, remediate parked | the cloud parked it after repeated failure or the round cap | read the last outcome card for the class; once its cause is fixed, the `unstick` skill releases it (`catalyst-skills release <ticket> --because <what changed>`); if the fix is a decision, file an ask |
30
30
  | no change hold | a remediate round changed nothing | a human comment on the ticket, or a new push to the branch, releases it; say what should change |
31
31
  | validate class spent, stale failure episode | the repair budget for this failure is used, or the ladder moved on | read the outcome cards; usually a decision about the approach, so an ask |
32
32
  | waiting on | a merge-gate failure with no automatic repair | read the merge-wait comment and the PR's three legs through `catalyst-github` |
@@ -52,7 +52,7 @@ Before anything reaches the human, answer three questions:
52
52
  2. **Does this need to block at all?** If a sane default exists, take it, record it, and file the ask anyway so the human can overrule; the work does not wait.
53
53
  3. **Who else can move this?** Another steward, the desk, a repository owner. Pulling in a peer is preferred over pulling in the human.
54
54
 
55
- Only a genuine product, priority or approval decision, or an action only a human can physically take (release a cloud park, resolve a repository setting, apply the release label, answer a merge policy question), survives to become an ask.
55
+ Only a genuine product, priority or approval decision, or an action only a human can physically take (close a person's own pull request, read a review that will not converge, resolve a repository setting, apply the release label, answer a merge policy question), survives to become an ask.
56
56
 
57
57
  ## When a stall becomes an ask
58
58
 
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
13
14
 
14
15
  export const PACKAGE_NAME = "@catalyst-cloud/catalyst-skills";
15
16
  export const NOT_CONFIGURED_EXIT = 2;
@@ -33,7 +34,7 @@ export function loadCustomerConfig() {
33
34
  if (!existsSync(path)) return null;
34
35
  try {
35
36
  const parsed = JSON.parse(readFileSync(path, "utf8"));
36
- if (!parsed || typeof parsed !== "object" || typeof parsed.key !== "string" || typeof parsed.account !== "string") return null;
37
+ if (!hasCredential(parsed) || typeof parsed.account !== "string") return null;
37
38
  return parsed;
38
39
  } catch {
39
40
  return null;
@@ -44,7 +45,7 @@ export function loadCustomerConfig() {
44
45
  export function requireConfigured() {
45
46
  const cfg = loadCustomerConfig();
46
47
  if (cfg) return cfg;
47
- console.error(`not connected: ${configPath()} is missing or unreadable — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE_NAME} login`);
48
+ console.error(`not connected: ${configPath()} is missing or unreadable — run: ${CONNECT_COMMAND}`);
48
49
  process.exit(NOT_CONFIGURED_EXIT);
49
50
  }
50
51
 
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
3
+ // byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
4
+ // (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
5
+ // each carries its own copy. This file is a library — run a sibling script with --help for usage.
6
+ //
7
+ // customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
8
+ // session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
9
+ // catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
10
+ // itself. A new credential kind lands here, once.
11
+
12
+ /** The command that connects this machine, as every not-connected line names it. */
13
+ export const CONNECT_COMMAND =
14
+ "npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
15
+
16
+ /** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
17
+ export function hasCredential(cfg) {
18
+ if (cfg === null || typeof cfg !== "object") return false;
19
+ const key = cfg["key"];
20
+ if (typeof key === "string" && key !== "") return true;
21
+ const login = cfg["auth"];
22
+ return (
23
+ login !== null &&
24
+ typeof login === "object" &&
25
+ login["kind"] === "oauth" &&
26
+ typeof login["refreshToken"] === "string" &&
27
+ login["refreshToken"] !== ""
28
+ );
29
+ }
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: unstick
3
+ description: >-
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-skills 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
+ disable-model-invocation: true
6
+ allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
7
+ ---
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills — written in this repository for customer tenants -->
9
+
10
+ # Unstick
11
+
12
+ You get one stuck ticket, or one set of tickets stuck for the same reason, moving again. The cloud parks or holds a ticket when retrying would only repeat a failure: after repeated failures, when the repair-round cap is spent, when a repair changed nothing, when the same validate failure recurred. Each of those is a governor. Once its cause is fixed, the person's own key releases it; nothing here needs an operator.
13
+
14
+ ## Run first
15
+
16
+ Scripts are run, never read. Each prints `--help`; exit 2 means this machine is not connected (run `catalyst-skills login`).
17
+
18
+ - `node scripts/unstick.mjs <ticket>` — one JSON document: the eligibility explanation, the execution history (every governor holding the ticket with what releases it, and past releases), and a dry-run release showing what a release would clear and refuse. Changes nothing.
19
+ - `node scripts/unstick.mjs <ticket> --because "<what changed>"` — the same, then the real release. Add `--retry-unchanged` only when you can say what changed outside what the cloud can see.
20
+ - `node scripts/unstick.mjs --class <failure-class> --team <key> [--because "<what changed>"]` — the same for every ticket on one team parked under one failure class (at most 25 per call).
21
+
22
+ The verbs underneath are `catalyst-skills explain <ticket>`, `catalyst-skills explain <ticket> --history` and `catalyst-skills release <ticket> --because <text> [--retry-unchanged] [--dry-run]`.
23
+
24
+ ## Load on demand
25
+
26
+ | when | read |
27
+ | -- | -- |
28
+ | deciding whether to release, what a refusal means, when to ask a person | `references/playbook.md` |
29
+ | what a reason in the explanation means | the `whats-happening` skill's why-is-it-stuck reference, or `how-catalyst-works` |
30
+ | a refusal needs a decision or a person's action | the `what-needs-me` skill |
31
+ | a refusal names a person's pull request or its review | the `catalyst-github` skill |
32
+
33
+ ## Rules
34
+
35
+ - **Read before you release.** Explain, then history, then a dry run. A release is never the first call.
36
+ - **The cause decides, not the wish to move.** Release when you can name what changed since the ticket was held: a push, a comment that says what to change, an outage that ended, a secret that was rotated, an account that was re-enrolled. Put that sentence in `--because`; it is recorded against your name.
37
+ - **Unchanged means say so.** The cloud refuses a release whose cause it cannot see change. Pass `--retry-unchanged` only with a `--because` that names the change it cannot see. If nothing changed, do not release; say so.
38
+ - **One release per cause.** If the history shows a release for the same failure and nothing has changed since, a second release buys the same failure. Raise an ask instead.
39
+ - **A refusal names its fix.** Do the fix when it is yours to do; everything else is one ask through `what-needs-me`, attached to an existing ask for the same decision. Never close or merge a person's pull request, and never answer an ask as the person.
40
+ - **A shared cause is one note.** Several tickets parked by one outage or one bad image are one class release and one line in your reply, never one ask per ticket.
41
+ - **Their tenant, as them.** Every read and write goes through the CLI and the person's own login. You never name another tenant.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "Unstick"
3
+ short_description: "Find out why a Catalyst ticket is parked or held and release it once its cause is fixed; asks only for what needs a person"
4
+ default_prompt: "Use $unstick to find out why <ticket> is stuck and get it moving again."
5
+ policy:
6
+ allow_implicit_invocation: false
@@ -0,0 +1,5 @@
1
+ identity: { pack: catalyst-cloud-skills, skill: unstick }
2
+ effects: [external-write]
3
+ mutating: true
4
+ invocation: explicit
5
+ exposure: [catalog]
@@ -0,0 +1,51 @@
1
+ # The unstick playbook
2
+
3
+ This reference restates how a person's release of a stuck ticket works and the order you follow. The cloud does the deciding: it reads every governor holding the ticket and either releases all of them or releases nothing and names, for each one it refuses, the action that does fix it. Your job is to read, judge whether the cause is fixed, and say so honestly.
4
+
5
+ ## The order
6
+
7
+ 1. **Why is nothing running?** `catalyst-skills explain <ticket>`. If the reason is not a park or a hold (for example `blocked`, `not_at_dispatch_stage`, an ask, a missing environment check, `scope_overlap`), there is nothing to release: follow the `whats-happening` skill's why-is-it-stuck reference instead and stop here.
8
+ 2. **What holds it, and has it been released before?** `catalyst-skills explain <ticket> --history`. Read three things: the "Held by" lines (each governor and what releases it), the last failure (its class and summary), and the "Releases" lines (who released it before, why, and what happened).
9
+ 3. **Is the cause fixed?** Decide from evidence, in this order:
10
+ - a push to the ticket's branch, a comment on the ticket that says what to change, or main moving on since the park: the cloud can see these, and a release goes through;
11
+ - a change the cloud cannot see (a provider outage that ended, a rotated secret, a re-enrolled coding account, a fixed tenant setting): release with `--retry-unchanged`, and the reason you give names that change;
12
+ - nothing changed: do not release. Tell the person what failed and what would have to change.
13
+ If a previous release in the history named the same failure and nothing has changed since, the next release will fail the same way. Do not release again; raise an ask.
14
+ 4. **Preview.** `catalyst-skills release <ticket> --dry-run`. It prints what a release would clear and what it would refuse, and changes nothing.
15
+ 5. **Release.** `catalyst-skills release <ticket> --because "<the change>"`. Report what it released, verbatim, with the ticket id. A warning line (for example, that a released repair round runs again at the escalated tier and a second cap parks it again) goes in your reply too.
16
+ 6. **Act on each refusal.** A refusal is terminal for that attempt: nothing was released. Each names its fix; the table below says who does it.
17
+
18
+ ## What a refusal means and who fixes it
19
+
20
+ | refusal | what it means | who acts |
21
+ | -- | -- | -- |
22
+ | `cause_unchanged` | nothing the cloud can see changed since it was held | you, if you can name a change it cannot see (`--retry-unchanged` with that `--because`); otherwise nobody should release it yet |
23
+ | `lease_held` | a phase is running on the ticket right now | nobody; wait for the phase's outcome comment, then look again |
24
+ | `branch_still_missing` | the ticket's branch was never pushed | it releases itself when the branch appears; check the earlier phases through `catalyst-linear` |
25
+ | `human_owned_pr` | a person opened the pull request this ticket would work on | that person closes or merges it, or hands it to Catalyst; never close a person's pull request yourself |
26
+ | `review_not_converging` | review and repair kept finding new problems | a person reads the findings (`catalyst-github`) and comments on the ticket to resume; raise an ask for that read |
27
+ | `round_threshold` | the ticket spent its lifetime repair budget | a person answers the ask the cloud already raised, or pushes a fix; point at that ask, do not raise a second one |
28
+ | `base_revision_lost` | the ticket's base commit is gone | whoever administers the tenant re-pins it; raise one ask |
29
+ | `remediate_cap` | this tenant keeps the repair-round cap for an administrator | whoever administers the tenant; raise one ask |
30
+ | `unknown_park` | a park this bundle does not know | raise one ask with the ticket id and the park name, verbatim |
31
+ | `held_beyond_class` | a class release reached a ticket held by more than that class | release that ticket on its own, from step 1 |
32
+ | `team_changed` (the ticket moved teams during the check) | the release was checked for one team and the ticket is now on another | run the release again |
33
+
34
+ Raise an ask only through `what-needs-me`, with the stuck ticket in what it blocks, and attach to an existing ask for the same decision rather than filing a second one.
35
+
36
+ ## Several tickets, one cause
37
+
38
+ When several tickets on one team are parked for the same failure class (the explanation and the history show the same class, typically an outage or a runner problem that has since ended):
39
+
40
+ 1. `catalyst-skills release --class <failure-class> --team <key> --dry-run` to see which tickets it reaches.
41
+ 2. `catalyst-skills release --class <failure-class> --team <key> --because "<the change>" --retry-unchanged` when the change is one the cloud cannot see.
42
+ 3. Report one line: how many released, which were refused and why, and whether more remain (run it again for the next batch).
43
+
44
+ A ticket the class release refuses is listed and left alone; take it through the single-ticket order above.
45
+
46
+ ## What a release does not do
47
+
48
+ - It does not skip a phase or accept work as done; a released phase runs again.
49
+ - It does not reset a lifetime budget. A released repair round still counts toward the ticket's lifetime threshold, and the cloud stops a loop that claims the same unit too often in an hour.
50
+ - It does not move the card. The ticket stays where it is and runs when the cloud next offers it.
51
+ - It is recorded: who released, with which login, why, what it cleared and what it refused. `explain --history` shows it to everyone who reads the ticket.