@catalyst-cloud/cli 0.8.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/bin/catalyst-skills.js +8 -0
  5. package/bin/catalyst.js +5 -0
  6. package/bin/launch.js +154 -0
  7. package/dist/args.js +280 -0
  8. package/dist/ask.js +161 -0
  9. package/dist/browser.js +20 -0
  10. package/dist/cli.js +397 -0
  11. package/dist/config.js +241 -0
  12. package/dist/contract-types.js +4 -0
  13. package/dist/contract.js +184 -0
  14. package/dist/detach.js +10 -0
  15. package/dist/environment.js +207 -0
  16. package/dist/errors.js +27 -0
  17. package/dist/events.js +106 -0
  18. package/dist/execution.js +451 -0
  19. package/dist/oauth.js +300 -0
  20. package/dist/pagination.js +76 -0
  21. package/dist/prompt.js +35 -0
  22. package/dist/published.js +79 -0
  23. package/dist/query.js +248 -0
  24. package/dist/ready.js +380 -0
  25. package/dist/release.js +142 -0
  26. package/dist/replica.js +614 -0
  27. package/dist/runtime-store.js +135 -0
  28. package/dist/runtime-verb.js +66 -0
  29. package/dist/runtime.js +87 -0
  30. package/dist/sdk.js +29 -0
  31. package/dist/secret.js +190 -0
  32. package/dist/semver.js +18 -0
  33. package/dist/skill-shape.js +189 -0
  34. package/dist/skills.js +129 -0
  35. package/dist/transport.js +205 -0
  36. package/dist/ts-deps-loader.js +113 -0
  37. package/dist/watch/consumer.js +141 -0
  38. package/dist/watch/cursor-file.js +62 -0
  39. package/dist/watch.js +175 -0
  40. package/dist/write.js +224 -0
  41. package/package.json +60 -0
  42. package/skills/catalyst-github/SKILL.md +35 -0
  43. package/skills/catalyst-github/agents/openai.yaml +6 -0
  44. package/skills/catalyst-github/agents/portability.yaml +4 -0
  45. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  46. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  47. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  48. package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
  49. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  50. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  51. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  52. package/skills/catalyst-linear/SKILL.md +43 -0
  53. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  54. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  55. package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
  56. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  57. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  58. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  59. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  60. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  61. package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
  62. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  63. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  64. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  65. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  66. package/skills/catalyst-onboard/SKILL.md +57 -0
  67. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  68. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  69. package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
  70. package/skills/catalyst-onboard/references/skill-sources.md +35 -0
  71. package/skills/catalyst-onboard/references/the-one-path.md +149 -0
  72. package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
  73. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  74. package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
  75. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  76. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  77. package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
  78. package/skills/catalyst-setup/SKILL.md +36 -0
  79. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  80. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  81. package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
  82. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  83. package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
  84. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  85. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  86. package/skills/connect-me/SKILL.md +63 -0
  87. package/skills/connect-me/agents/openai.yaml +6 -0
  88. package/skills/connect-me/agents/portability.yaml +5 -0
  89. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  90. package/skills/connect-me/scripts/lib/cli.mjs +185 -0
  91. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  92. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  93. package/skills/how-catalyst-works/SKILL.md +43 -0
  94. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  95. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  96. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  97. package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
  98. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  99. package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
  100. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  101. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  102. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  103. package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
  104. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  105. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  106. package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
  107. package/skills/run-this-project/SKILL.md +45 -0
  108. package/skills/run-this-project/agents/openai.yaml +6 -0
  109. package/skills/run-this-project/agents/portability.yaml +5 -0
  110. package/skills/run-this-project/assets/stall-policy.json +15 -0
  111. package/skills/run-this-project/references/making-work-ready.md +60 -0
  112. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  113. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  114. package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
  115. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  116. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  117. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  118. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  119. package/skills/unstick/SKILL.md +41 -0
  120. package/skills/unstick/agents/openai.yaml +6 -0
  121. package/skills/unstick/agents/portability.yaml +5 -0
  122. package/skills/unstick/references/playbook.md +51 -0
  123. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  124. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  125. package/skills/unstick/scripts/unstick.mjs +57 -0
  126. package/skills/what-needs-me/SKILL.md +41 -0
  127. package/skills/what-needs-me/agents/openai.yaml +6 -0
  128. package/skills/what-needs-me/agents/portability.yaml +5 -0
  129. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  130. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  131. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  132. package/skills/what-needs-me/scripts/inbox.mjs +56 -0
  133. package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
  134. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  135. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  136. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  137. package/skills/whats-happening/SKILL.md +43 -0
  138. package/skills/whats-happening/agents/openai.yaml +6 -0
  139. package/skills/whats-happening/agents/portability.yaml +4 -0
  140. package/skills/whats-happening/assets/status-reply.json +77 -0
  141. package/skills/whats-happening/references/reading-the-board.md +43 -0
  142. package/skills/whats-happening/references/reprioritising.md +37 -0
  143. package/skills/whats-happening/references/routing-work.md +36 -0
  144. package/skills/whats-happening/references/status-reply.md +34 -0
  145. package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
  146. package/skills/whats-happening/scripts/explain.mjs +28 -0
  147. package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
  148. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  149. package/skills/whats-happening/scripts/snapshot.mjs +149 -0
  150. package/vendor/README.md +9 -0
  151. package/vendor/paths/index.d.ts +85 -0
  152. package/vendor/paths/index.js +148 -0
  153. package/vendor/paths/legacy-installer.d.ts +36 -0
  154. package/vendor/paths/legacy-installer.js +154 -0
  155. package/vendor/paths/node.d.ts +18 -0
  156. package/vendor/paths/node.js +102 -0
  157. package/vendor/paths/provenance.json +17 -0
@@ -0,0 +1,76 @@
1
+ # Reacting to events
2
+
3
+ This reference restates invariants of the Catalyst Cloud stream and the `watch` verb. Nothing here varies per tenant; the tenant facts a reaction needs (stage ids, label ids, the ask team) come from `catalyst-skills contract` at the moment you need them.
4
+
5
+ ## The mechanism
6
+
7
+ A steward never polls. It subscribes once to the tenant stream through the SDK's live client and reacts to each change as it arrives. The verb is `catalyst-skills watch`, and this skill's `scripts/watch-scope.mjs` is that verb with the scope checked up front:
8
+
9
+ ```sh
10
+ node scripts/watch-scope.mjs --project <id> # every ticket in the project
11
+ node scripts/watch-scope.mjs --team <key> # every ticket on the team
12
+ node scripts/watch-scope.mjs --ticket ENG-41 --ticket ENG-42
13
+ ```
14
+
15
+ The stream itself is tenant-wide; scope is filtered on this machine from each frame's entity and row, and a row that only carries a ticket reference (a comment, a session, a pull request) is resolved to its project through the ticket's own record, cached for the life of the watch. The cost of an ignored frame is one JSON parse, so a wide scope is fine.
16
+
17
+ **In Claude Code**, arm a monitor on the command above. Each line it prints is one applied change inside the scope, delivered into your session as an event. React to it in the same turn: read what changed, decide, act, and only then let the turn end.
18
+
19
+ **In Codex, OpenCode, or any harness without a monitor**, pass `--exec`:
20
+
21
+ ```sh
22
+ node scripts/watch-scope.mjs --project <id> --exec 'node my-reaction.mjs'
23
+ ```
24
+
25
+ The command runs once per frame with the frame on stdin. A non-zero exit is a failed reaction (see the cursor rule). Nothing polls in either shape.
26
+
27
+ ## The frame
28
+
29
+ One JSON object per line, exactly what the SDK delivers:
30
+
31
+ ```json
32
+ {"type":"change","accountId":"<tenant>","seq":4182,"entity":"comments","entityId":"<id>","op":"upsert","row":{...}}
33
+ ```
34
+
35
+ `seq` is the tenant-wide position. `entity` is one of the mirror's feed tables. `op` is `upsert` or `delete`. `row` is the mirrored row when the op is an upsert; a delete carries the id only. A frame for another tenant is refused by the CLI and never printed.
36
+
37
+ ## Entities that matter to a scope
38
+
39
+ | entity | what a change means | first reaction |
40
+ | -- | -- | -- |
41
+ | `issues` | a card moved, was retitled, re-prioritised, assigned, or created | if it moved into a stage the cloud owns, note the advance; if it moved out of the ladder by hand, ask why before touching it |
42
+ | `comments` | someone or something wrote on a ticket | a human comment in your scope is answered in-thread by you, tagged; a cloud outcome card (phase complete, phase failed, remediate attempt, board health, merge wait) is your execution signal, read it |
43
+ | `issue_labels`, `labels` | a label was added or removed | an ask label means a question, not work; a hold label means the cloud wants a human; the release label means a human cleared a false positive |
44
+ | `relations` | a blocks relation appeared or went away | a new blocker on your ticket is a dependency to chase; a blocker closing may make a card dispatchable again |
45
+ | `pull_requests`, `pr_events`, `pushes` | the branch and PR state changed | a PR going ready-for-review or merged is a milestone; a force-push after a resolved review thread is worth a look |
46
+ | `check_runs`, `check_suites`, `commit_statuses` | CI moved | red on a required check after the queue label wakes an automatic remediation; you watch, you do not re-run it |
47
+ | `reviews`, `pr_review_threads`, `pr_review_comments` | the reviewer spoke | unresolved threads block the merge; a clean pass is a reaction or a terse comment, not a review object |
48
+ | `agent_sessions`, `agent_activities` | a phase started, wrote an artifact, opened a PR, reported | the plan on the session is the ladder itself; the current phase is the one in progress |
49
+ | `fleet_activity`, `fleet_host_liveness` | a runner picked up or dropped a phase | a phase running is not a stall, whatever the clock says |
50
+ | `fleet_anomalies` | the cloud raised a fleet-level alert | one alert covers every ticket it touches; never escalate it per ticket |
51
+ | `workflow_states`, `team_workflow_mapping` | the tenant's stage map changed | refresh the contract (`catalyst-skills contract --refresh`) before the next state move |
52
+ | `projects`, `cycles`, `initiatives` | your scope's container changed | update the status summary |
53
+
54
+ Entities not listed still arrive when they are in scope; ignore what you do not need.
55
+
56
+ ## The same-turn rule
57
+
58
+ A frame is reacted to in the turn it arrives, not batched for a later pass. The reaction is whatever the table above says plus anything the situation obviously needs, and it ends with the one status summary for the scope being current. If a reaction needs a human decision, file the ask through `what-needs-me` before proceeding on the default, in that same turn.
59
+
60
+ ## The cursor rule
61
+
62
+ The cursor file (`~/.config/catalyst-cloud/watch-cursor.json`, stamped with the tenant) advances only after the reaction returns. A reaction that throws, or an `--exec` command that exits non-zero, leaves the cursor at the last good frame; the CLI closes the socket and the SDK's reconnect replays from that cursor, so the failed frame is offered again. Delivery is therefore at-least-once: make reactions safe to repeat (check before you write, prefer idempotent writes such as a label add over a fresh comment).
63
+
64
+ Two consequences. First, a crash mid-reaction replays, never loses. Second, a reaction that keeps failing keeps the watch pinned on one frame; fix the reaction rather than skipping the frame, or restart with `--from head` and re-read the scope with `scripts/scope-status.mjs` to catch up on what you missed.
65
+
66
+ ## What to do on resync
67
+
68
+ When the tenant tells the stream it can no longer replay from your cursor, the CLI prints one line on standard error, `[watch] resync: cursor moved to head <n>`, and continues from the head. No rows are copied. Anything that happened between your old cursor and the head is not replayed, so a resync is your cue to run `scripts/scope-status.mjs` once and reconcile the summary against the live state.
69
+
70
+ ## Starting a watch on an existing project
71
+
72
+ Start with `--from head` when you are picking up a project that has been running without a steward; the backlog of old frames is not worth replaying, and `scope-status.mjs` gives you the present state in one call. Start from the saved cursor (the default) when you are resuming your own watch after a break, so nothing that happened in between is lost.
73
+
74
+ ## The honest limit
75
+
76
+ The stream carries the mirror's entity changes. It does not carry the relay ledger's phase failures, parks, remediate rounds or backoff timers as first-class frames. Between frames you learn those from the outcome comments the cloud posts on the ticket, which do arrive as `comments` frames: a phase-failed card names the phase, the attempt and the failure class; a remediate-attempt card names the round; a board-health comment names a stall the cloud itself noticed. For the current verdict on any ticket, ask the explainer: `catalyst-skills explain <ticket>` turns the exclusion reason and the last failure into one paragraph. `catalyst-skills explain --history <ticket>` prints the per-phase attempt ledger, the remediation rounds against the cap, and any park with what releases it.
@@ -0,0 +1,63 @@
1
+ # Stalls and escalation
2
+
3
+ The stall thresholds here are **local policy**, set once by the human who owns the tenant and read by `scripts/scope-status.mjs`. They are not cloud values and the cloud does not enforce them; the cloud's own enforcement numbers (retry backoff, the park threshold, the remediate round cap, the write budget) are on the contract and printed by `how-catalyst-works`. The chase order and the escalation rule below are invariants.
4
+
5
+ ## The policy file
6
+
7
+ `assets/stall-policy.json` ships the defaults: minutes a ticket may sit in each slot with no update and nothing running or leased on it before it counts as stalled. To change them, copy that file to `~/.config/catalyst-cloud/stall-policy.json` and edit it there; the script prefers the local copy and prints which file it used. Pass `--stall-policy <file>` to use another.
8
+
9
+ Two slots deserve thought when tuning. The dispatch slot's threshold is how long a card may wait to be picked up before you ask why nothing took it; on a busy tenant with a full queue that is capacity, not a stall, and the answer is to wait or re-prioritise. The PR slot's threshold is how long a card may wait for merge evidence; reviews and CI take real time, so set it generously.
10
+
11
+ ## What counts as stuck
12
+
13
+ A ticket is stuck when all of these hold at once: it is in an active slot (not done, not canceled), nothing is running or leased on it, its last update is older than the slot's threshold, and the eligibility explainer does not name a reason that is simply waiting out a clock. `scope-status.mjs` checks the first three and hands you the fourth as a command to run.
14
+
15
+ A running phase is never a stall, however long it has run; the cloud's own timeout will fail it and the outcome card will say so. A ticket in retry backoff is waiting out a rung of two, five or fifteen minutes and is not a stall either.
16
+
17
+ ## The chase order
18
+
19
+ Run `catalyst-skills explain <ticket>` first, and let its reason pick the row:
20
+
21
+ | the explainer says | what it means | your move |
22
+ | -- | -- | -- |
23
+ | offered, with a position | the queue has it; a runner is the bottleneck | wait; if every ticket waits, it is capacity, see below |
24
+ | lease held, intake lease held | a container has it | not a stall; wait for the outcome card |
25
+ | retry backoff | a pre-branch or infrastructure failure is retrying in place | wait out the rung; three in a row parks it, and then it is a real stall |
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
+ | 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
+ | 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
+ | 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
+ | 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
+ | 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
+ | waiting on | a merge-gate failure with no automatic repair | read the merge-wait comment and the PR's three legs through `catalyst-github` |
33
+ | branch missing, branch gone, no branch to remediate | the branch the phase needs does not exist | a hand-deleted branch is a human question; a never-created one means implement has not run, so check the earlier phases |
34
+ | environment check required, running, failed, expired, hash mismatch | the repository's environment gate | a repository setting; the tenant admin resolves it in settings, one ask for the repository, not per ticket |
35
+ | scope overlap | another in-flight ticket owns the files | wait for it, or re-order by priority |
36
+ | routing unavailable, no eligible slot, runner image breaker, repo paused | a fleet or provider condition | one fleet note, never per ticket; see below |
37
+ | pr merged, pipeline complete, ticket terminal | it is finished | close the loop in the summary; if the card is not Done a minute after the merge, that is a finding, not a chore |
38
+
39
+ Where the explainer names a reason not in this table, it prints the raw reason; read it as spelled and consult `how-catalyst-works`.
40
+
41
+ ## Capacity and fleet conditions are one note, not many asks
42
+
43
+ When several tickets in scope are offered and nothing picks them up, or the explainer names a routing, slot, provider or image condition, the cause is shared: coding-account headroom, a provider outage, a paused repository, a poisoned runner image. Write one line in the status summary naming the condition and the tickets it holds. Do not file an ask per ticket. `catalyst-skills accounts` reads coding-account status (state, usage windows, walls, quarantine); enrolling, pausing or removing an account is the settings page, not you.
44
+
45
+ ## Escalate inward, never outward
46
+
47
+ The order is instrument, then you (the steward), then the desk (`whats-happening`), then the human, and the human only ever sees an ask. An instrument or a script that pages a human directly is a defect. So is a stall report that reaches the human as a bare label, a board row, or a chat aside with no ask behind it.
48
+
49
+ Before anything reaches the human, answer three questions:
50
+
51
+ 1. **Can I decide this myself?** Which approach, retry or abandon, rebase or re-cut, re-order two tickets: yours. Decide, record the decision in a bookkeeping note on the ticket, and move on.
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
+ 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
+
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
+
57
+ ## When a stall becomes an ask
58
+
59
+ File it through `what-needs-me`, never by hand and never as the human. The ask carries the question as its title, the options, the default that fires if silent, and what it blocks, with the stalled ticket named in the blocks list so the human's inbox ranks it by what it holds. Search for an existing ask about the same decision first and attach the new ticket to it rather than filing a second one; two asks for one decision split its urgency and sink both. Once filed, proceed on the default and say so in the ticket.
60
+
61
+ ## Reply where the message arrived
62
+
63
+ A human comment inside your scope is answered by you, in that thread, tagged, as the app actor. A question only a human can decide becomes an ask; you do not answer it, and you never post as the human. A bookkeeping record (a state move, a decision you took, a chain summary) carries the contract's bookkeeping marker as a prefix so it wakes nothing; the `--bookkeeping` flag on `catalyst-skills write comment` adds it.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ // lib/cli.mjs — the one way a skill script reaches Catalyst Cloud: by spawning the catalyst-skills
3
+ // CLI. The CLI holds the SDK and the key; this file holds neither. It reads
4
+ // ~/.config/catalyst-cloud/customer.json (under CATALYST_SKILLS_HOME when set, else HOME) for the
5
+ // CLI path that login recorded and falls back to `npx @catalyst-cloud/catalyst-skills`.
6
+ //
7
+ // Exit codes every script built on this file shares: 2 = this machine is not connected, 1 = the
8
+ // script's own check failed, 0 = fine.
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
14
+
15
+ export const PACKAGE_NAME = "@catalyst-cloud/catalyst-skills";
16
+ export const NOT_CONFIGURED_EXIT = 2;
17
+ export const CHECK_FAILED_EXIT = 1;
18
+
19
+ export function homeDir() {
20
+ return process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? "/";
21
+ }
22
+
23
+ export function configDir() {
24
+ return join(homeDir(), ".config", "catalyst-cloud");
25
+ }
26
+
27
+ export function configPath() {
28
+ return join(configDir(), "customer.json");
29
+ }
30
+
31
+ /** The stored config, or null when the file is absent or unreadable. Never throws. */
32
+ export function loadCustomerConfig() {
33
+ const path = configPath();
34
+ if (!existsSync(path)) return null;
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
37
+ if (!hasCredential(parsed) || typeof parsed.account !== "string") return null;
38
+ return parsed;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Print the one not-connected line and exit 2. */
45
+ export function requireConfigured() {
46
+ const cfg = loadCustomerConfig();
47
+ if (cfg) return cfg;
48
+ console.error(`not connected: ${configPath()} is missing or unreadable — run: ${CONNECT_COMMAND}`);
49
+ process.exit(NOT_CONFIGURED_EXIT);
50
+ }
51
+
52
+ /** The command and argument prefix that reaches the CLI on this machine. */
53
+ export function cliCommand(cfg = loadCustomerConfig()) {
54
+ if (cfg && typeof cfg.cliPath === "string" && existsSync(cfg.cliPath)) {
55
+ return { command: process.execPath, prefix: [cfg.cliPath], via: `node ${cfg.cliPath}` };
56
+ }
57
+ const npx = process.platform === "win32" ? "npx.cmd" : "npx";
58
+ return { command: npx, prefix: [PACKAGE_NAME], via: `npx ${PACKAGE_NAME}` };
59
+ }
60
+
61
+ /**
62
+ * Run one CLI verb and capture its output. Resolves `{code, stdout, stderr, notConfigured}`;
63
+ * `notConfigured` is true when the CLI itself said the machine is not connected.
64
+ */
65
+ export function runCli(args, { stdin } = {}) {
66
+ const { command, prefix } = cliCommand();
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn(command, [...prefix, ...args], {
69
+ stdio: [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
70
+ env: process.env,
71
+ shell: process.platform === "win32",
72
+ });
73
+ let stdout = "";
74
+ let stderr = "";
75
+ child.stdout.on("data", (d) => (stdout += String(d)));
76
+ child.stderr.on("data", (d) => (stderr += String(d)));
77
+ child.on("error", reject);
78
+ child.on("close", (code) => {
79
+ resolve({ code: code ?? 1, stdout, stderr, notConfigured: /not (joined|connected)/i.test(stderr) || /not (joined|connected)/i.test(stdout) });
80
+ });
81
+ if (stdin !== undefined) child.stdin.end(stdin);
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Run one CLI verb with the terminal attached, so a long-running verb such as `watch` streams
87
+ * straight through. Resolves with the exit code; SIGINT and SIGTERM are forwarded to the child.
88
+ */
89
+ export function execCli(args) {
90
+ const { command, prefix } = cliCommand();
91
+ return new Promise((resolve, reject) => {
92
+ const child = spawn(command, [...prefix, ...args], { stdio: "inherit", env: process.env, shell: process.platform === "win32" });
93
+ const forward = (sig) => () => {
94
+ try {
95
+ child.kill(sig);
96
+ } catch {
97
+ // already gone
98
+ }
99
+ };
100
+ const onInt = forward("SIGINT");
101
+ const onTerm = forward("SIGTERM");
102
+ process.on("SIGINT", onInt);
103
+ process.on("SIGTERM", onTerm);
104
+ child.on("error", reject);
105
+ child.on("close", (code) => {
106
+ process.off("SIGINT", onInt);
107
+ process.off("SIGTERM", onTerm);
108
+ resolve(code ?? 1);
109
+ });
110
+ });
111
+ }
112
+
113
+ /** Parse the CLI's --json output; a parse failure names the first line of what came back. */
114
+ export function parseJson(stdout) {
115
+ try {
116
+ return JSON.parse(stdout);
117
+ } catch {
118
+ throw new Error(`the CLI did not answer with JSON: ${stdout.split("\n")[0] ?? "(empty)"}`);
119
+ }
120
+ }
121
+
122
+ /** Exit 2 with the CLI's own not-connected line when a call reports it; otherwise return the result. */
123
+ export function guard(result) {
124
+ if (result.notConfigured) {
125
+ process.stderr.write(result.stderr || result.stdout);
126
+ process.exit(NOT_CONFIGURED_EXIT);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ /** A tiny flag parser: `--name value`, `--name=value`, `--flag`, repeatable names collected as arrays. */
132
+ export function parseFlags(argv, { values = [], repeat = [], booleans = [] } = {}) {
133
+ const flags = {};
134
+ const positionals = [];
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const a = argv[i];
137
+ if (a === "--") {
138
+ positionals.push(...argv.slice(i + 1));
139
+ break;
140
+ }
141
+ if (!a.startsWith("--")) {
142
+ positionals.push(a);
143
+ continue;
144
+ }
145
+ let name = a.slice(2);
146
+ let value;
147
+ const eq = name.indexOf("=");
148
+ if (eq !== -1) {
149
+ value = name.slice(eq + 1);
150
+ name = name.slice(0, eq);
151
+ }
152
+ if (booleans.includes(name)) {
153
+ flags[name] = true;
154
+ continue;
155
+ }
156
+ if (!values.includes(name) && !repeat.includes(name)) {
157
+ console.error(`unknown option: --${name} (try --help)`);
158
+ process.exit(CHECK_FAILED_EXIT);
159
+ }
160
+ if (value === undefined) {
161
+ value = argv[++i];
162
+ if (value === undefined) {
163
+ console.error(`--${name} needs a value`);
164
+ process.exit(CHECK_FAILED_EXIT);
165
+ }
166
+ }
167
+ if (repeat.includes(name)) flags[name] = [...(flags[name] ?? []), value];
168
+ else flags[name] = value;
169
+ }
170
+ return { flags, positionals };
171
+ }
172
+
173
+ export function wantsHelp(argv) {
174
+ return argv.includes("--help") || argv.includes("-h");
175
+ }
176
+
177
+ const HELP = `lib/cli.mjs — shared helper; not a command.
178
+
179
+ Resolves the catalyst-skills CLI (the path login recorded in ${configPath()}, else npx ${PACKAGE_NAME})
180
+ and runs one verb for the script that imports it. Run any sibling script with --help instead.`;
181
+
182
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
183
+ console.log(HELP);
184
+ process.exit(0);
185
+ }
@@ -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,64 @@
1
+ #!/usr/bin/env node
2
+ // make-ready.mjs — the two card moves a steward makes. Dispatch is a move into the team's dispatch
3
+ // slot; parking is a move into the team's backlog-type state, which is not a slot, so the CLI
4
+ // resolves it from the team's live workflow states. Every id comes from the contract or the live
5
+ // state list; nothing here names a stage.
6
+ import { CHECK_FAILED_EXIT, guard, parseFlags, parseJson, requireConfigured, runCli, wantsHelp } from "./lib/cli.mjs";
7
+
8
+ const HELP = `Usage: node scripts/make-ready.mjs <ticket> [--park] [--note <text>] [--json]
9
+
10
+ Without --park: moves the card into the team's dispatch column, so the cloud offers its next phase.
11
+ With --park: moves the card into the team's backlog-type state, so nothing further is offered.
12
+
13
+ Options:
14
+ --note <text> also post a bookkeeping comment saying why (prefixed with the contract's marker,
15
+ so it wakes nothing)
16
+ --json print the CLI's JSON answers instead of the summary lines
17
+ --help this text
18
+
19
+ After a dispatch move the script asks the eligibility explainer about the ticket and prints its
20
+ verdict. A verdict about a stale or unpublished ordering right after a move is normal: the cloud
21
+ re-derives the queue within a pass; ask again in a minute.
22
+
23
+ Exit codes: 2 not connected; 1 the move was refused (the CLI's reason is printed); 0 moved.`;
24
+
25
+ const argv = process.argv.slice(2);
26
+ if (wantsHelp(argv)) {
27
+ console.log(HELP);
28
+ process.exit(0);
29
+ }
30
+ const { flags, positionals } = parseFlags(argv, { values: ["note"], booleans: ["park", "json"] });
31
+ const ticket = positionals[0];
32
+ if (!ticket || positionals.length !== 1) {
33
+ console.error("make-ready needs exactly one ticket identifier (see --help)");
34
+ process.exit(CHECK_FAILED_EXIT);
35
+ }
36
+ requireConfigured();
37
+
38
+ const moveArgs = flags.park ? ["write", "state", ticket, "--state-type", "backlog"] : ["write", "state", ticket, "--slot", "dispatch"];
39
+ const moved = guard(await runCli([...moveArgs, "--json"]));
40
+ if (moved.code !== 0) {
41
+ process.stderr.write(moved.stderr || moved.stdout);
42
+ process.exit(CHECK_FAILED_EXIT);
43
+ }
44
+ const out = { ticket, action: flags.park ? "parked" : "dispatched", move: parseJson(moved.stdout) };
45
+
46
+ if (flags.note) {
47
+ const body = `${flags.park ? "parked" : "dispatched"} by run-this-project: ${flags.note}`;
48
+ const noted = guard(await runCli(["write", "comment", ticket, "--bookkeeping", "--body", body, "--json"]));
49
+ out.note = noted.code === 0 ? parseJson(noted.stdout) : { failed: (noted.stderr || noted.stdout).trim() };
50
+ }
51
+
52
+ if (!flags.park) {
53
+ const explained = guard(await runCli(["explain", ticket, "--json"]));
54
+ out.eligibility = explained.code === 0 ? parseJson(explained.stdout) : { failed: (explained.stderr || explained.stdout).trim() };
55
+ }
56
+
57
+ if (flags.json) {
58
+ console.log(JSON.stringify(out));
59
+ } else {
60
+ console.log(`${ticket}: ${out.action}`);
61
+ if (out.note) console.log(out.note.failed ? `note: not posted (${out.note.failed})` : "note: posted as a bookkeeping comment");
62
+ if (out.eligibility) console.log(out.eligibility.failed ? `eligibility: unknown (${out.eligibility.failed})` : `eligibility: ${out.eligibility.explanation}`);
63
+ }
64
+ process.exit(0);
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // watch-scope.mjs — subscribe to the tenant stream filtered to one scope and pass every in-scope
3
+ // frame through, one JSON line each. This is `catalyst-skills watch` with the scope flags checked
4
+ // up front; the CLI owns the socket, the cursor file, the replay and the reconnects.
5
+ import { CHECK_FAILED_EXIT, execCli, parseFlags, requireConfigured, wantsHelp } from "./lib/cli.mjs";
6
+
7
+ const HELP = `Usage: node scripts/watch-scope.mjs (--project <id> | --team <key> | --ticket <id>...) [options]
8
+
9
+ Streams one JSON line per change inside the scope: a ticket, a comment, a pull request, a check, a
10
+ review thread, an agent session or a fleet anomaly. Runs until the session ends (Ctrl-C).
11
+
12
+ Scope (at least one; they combine):
13
+ --project <id> every ticket in this Linear project, resolved through the ticket's own record
14
+ --team <key> every ticket whose identifier carries this team key
15
+ --ticket <id> one ticket (repeatable)
16
+
17
+ Options:
18
+ --exec <command> run this shell command once per frame with the frame on stdin; a non-zero exit
19
+ is a failed reaction, so the cursor holds and the frame is offered again
20
+ --from cursor|head start from the saved cursor (default) or skip straight to the tenant head
21
+ --cursor-file <p> where the cursor lives (default ~/.config/catalyst-cloud/watch-cursor.json)
22
+ --help this text
23
+
24
+ Exit codes: 2 not connected; 1 no scope given; otherwise the CLI's own exit code.
25
+
26
+ In Claude Code, arm a monitor on this command and react to each line in the same turn. In a harness
27
+ with no monitor, pass --exec so the reaction still happens per frame and nothing polls.`;
28
+
29
+ const argv = process.argv.slice(2);
30
+ if (wantsHelp(argv)) {
31
+ console.log(HELP);
32
+ process.exit(0);
33
+ }
34
+ const { flags, positionals } = parseFlags(argv, {
35
+ values: ["project", "team", "exec", "from", "cursor-file"],
36
+ repeat: ["ticket"],
37
+ });
38
+ if (positionals.length > 0) {
39
+ console.error(`unexpected argument: ${positionals[0]} (scope is given with --project, --team or --ticket)`);
40
+ process.exit(CHECK_FAILED_EXIT);
41
+ }
42
+ if (!flags.project && !flags.team && !(flags.ticket && flags.ticket.length)) {
43
+ console.error("watch-scope needs a scope: --project <id>, --team <key> or --ticket <id> (see --help)");
44
+ process.exit(CHECK_FAILED_EXIT);
45
+ }
46
+ if (flags.from && flags.from !== "cursor" && flags.from !== "head") {
47
+ console.error("--from must be cursor or head");
48
+ process.exit(CHECK_FAILED_EXIT);
49
+ }
50
+ requireConfigured();
51
+
52
+ const args = ["watch"];
53
+ if (flags.project) args.push("--project", flags.project);
54
+ if (flags.team) args.push("--team", flags.team);
55
+ for (const t of flags.ticket ?? []) args.push("--ticket", t);
56
+ if (flags.exec) args.push("--exec", flags.exec);
57
+ if (flags.from) args.push("--from", flags.from);
58
+ if (flags["cursor-file"]) args.push("--cursor-file", flags["cursor-file"]);
59
+
60
+ const code = await execCli(args);
61
+ process.exit(code);
@@ -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@0.8.0 — 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.