@catalyst-cloud/catalyst-skills 0.4.0 → 0.7.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 (73) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +19 -12
  3. package/dist/args.js +34 -4
  4. package/dist/ask.js +6 -3
  5. package/dist/cli.js +19 -5
  6. package/dist/config.js +4 -0
  7. package/dist/contract.js +6 -0
  8. package/dist/environment.js +207 -0
  9. package/dist/events.js +106 -0
  10. package/dist/execution.js +146 -18
  11. package/dist/pagination.js +76 -0
  12. package/dist/published.js +75 -0
  13. package/dist/query.js +41 -18
  14. package/dist/ready.js +110 -19
  15. package/dist/release.js +142 -0
  16. package/dist/replica.js +296 -30
  17. package/dist/semver.js +18 -0
  18. package/dist/skill-shape.js +10 -0
  19. package/dist/skills.js +52 -1
  20. package/dist/transport.js +5 -2
  21. package/dist/ts-deps-loader.js +46 -1
  22. package/package.json +7 -4
  23. package/skills/catalyst-github/SKILL.md +1 -1
  24. package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
  25. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  26. package/skills/catalyst-linear/SKILL.md +1 -1
  27. package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
  28. package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
  29. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  30. package/skills/catalyst-onboard/SKILL.md +49 -0
  31. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  32. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  33. package/skills/catalyst-onboard/references/the-one-path.md +143 -0
  34. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  35. package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
  36. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  37. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  38. package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
  39. package/skills/catalyst-setup/SKILL.md +3 -3
  40. package/skills/catalyst-setup/references/what-each-check-means.md +22 -6
  41. package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
  42. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  43. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  44. package/skills/connect-me/SKILL.md +1 -1
  45. package/skills/connect-me/scripts/lib/cli.mjs +3 -2
  46. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  47. package/skills/how-catalyst-works/SKILL.md +1 -1
  48. package/skills/how-catalyst-works/references/stages-and-mapping.md +2 -2
  49. package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
  50. package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
  51. package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
  52. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  53. package/skills/run-this-project/SKILL.md +2 -2
  54. package/skills/run-this-project/references/making-work-ready.md +1 -1
  55. package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
  56. package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
  57. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  58. package/skills/unstick/SKILL.md +41 -0
  59. package/skills/unstick/agents/openai.yaml +6 -0
  60. package/skills/unstick/agents/portability.yaml +5 -0
  61. package/skills/unstick/references/playbook.md +51 -0
  62. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  63. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  64. package/skills/unstick/scripts/unstick.mjs +57 -0
  65. package/skills/what-needs-me/SKILL.md +1 -1
  66. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  67. package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
  68. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  69. package/skills/whats-happening/SKILL.md +2 -2
  70. package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
  71. package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
  72. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  73. package/skills/whats-happening/scripts/snapshot.mjs +4 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ `explain` and `ready` now name a team that cannot start work even when the live read is not available. Your tenant's contract carries each team's dispatch gate, and this machine already keeps a copy of that contract on disk; until now only the live eligibility read could name the gate, so a network hiccup, or a cloud older than that read, left `explain` printing nothing at all. `explain` now leads with the gate and the fix from the cached contract when the live read is unavailable or sends no gate, and says which of the two it read; when both answer and disagree, the live read wins and the paragraph says the cached one disagreed. A refusal from the cloud — a credential that is not accepted, for instance — is still a refusal, never quietly replaced by a cached answer. `ready` now prints one dispatch-gate line per team: open teams read `ok`, and a team whose stages are not saved reads `FAIL` with the remedy your tenant sent and turns the verdict to NOT READY, because nothing in that team can start. A cloud that does not send the gate changes nothing.
6
+
7
+ `catalyst-skills ready` now tells you when the skills on this machine, or the CLI itself, are behind the newest published release — it names the version you have, the version that is out, and the one command that updates each, as a note that never changes the verdict. Every skill file now records the bundle version it was vendored at, so a copy installed months ago is visible as such instead of only by diffing files. The check reads the npm registry's `dist-tags.latest`, capped at 1500ms and cached for six hours, and never blocks: an unreachable registry is one note that says so, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup entirely.
8
+
9
+ A list read now returns the whole scope or says plainly that it did not. `catalyst-skills query issues --all` and `query pulls --all` follow the cloud's page cursor to the end of the scope instead of stopping at the first page, and a read without `--all` that was cut short now prints `truncated at N of M` on stderr, where M is the full count the cloud reports for the same scope. `catalyst-skills ask list` — and the what-needs-me skill that wraps it — reads every page, so a workspace with more than a few hundred tickets no longer gets an inbox that quietly omits the asks past the first page, or scores the ones it does show against a partial view of what they hold.
10
+
11
+ Every skill this bundle ships now carries a routing case proving it still fires on a sentence its own description promises, and a deterministic check that fails by name the moment a description drops a phrase a case relies on — this repository's own test suite now catches a broken trigger before it ever reaches an install. A publish also runs a security scanner over every skill's scripts first, so a script that reads a sensitive directory or embeds an instruction override blocks the release unless someone has written down why it does not.
12
+
13
+ ## 0.6.1
14
+
15
+ The local replica writer no longer retries a failing snapshot pull forever. After a failed or incomplete pull it backs off with jitter (30s doubling to a 15-minute cap) and gives up after five consecutive failures, recording why; a pull that completes resets the count. `catalyst-skills replica status` and `catalyst-skills ready` both name the stopped state, the count, the last error, and the command that restarts it. Until the read side of a large snapshot is safe, `ready` no longer suggests starting the replica at all — it says plainly that the replica is optional and off by default for large tenants, and every read still works through the API either way.
16
+
17
+ ## 0.6.0
18
+
19
+ A new `catalyst-onboard` skill walks you from nothing to your first ticket running, one step at a time. You install the bundle yourself and type `/catalyst-onboard`; from there your agent does each step, shows you what actually came back, and stops — no page is fetched and no instruction arrives from anywhere but the skill you installed. It reads setup as five separate parts, each with the instrument that owns it: this machine (`status` and the machine half of `ready`), you (`me`), your account's Linear workspace, the projects that have been mapped, and the repositories that have been registered. Nothing answers for a part it does not own, so a project that is not ready is reported as a project finding with the tenant owner who can fix it and the page it is on — never as something to retry here. `node scripts/where-am-i.mjs` prints the whole reading, `--next` reduces it to the one next step, and every page link it prints is built from the cloud this machine is connected to rather than typed from memory.
20
+
21
+ It also says plainly what it cannot do. Approving the login, connecting Linear and installing the GitHub App are browser steps by construction and always will be. Seeing every project you could set up, mapping stages, adopting the workflow, registering a repository and declaring an environment are settings-page steps today because the routes behind them take a browser session rather than a key; the skill hands you the page for each, says it is a gap rather than the design, and never guesses at a route. Two silences are called out because both read as absence and are not: an empty project list means nothing is mapped yet, not that you have no projects, and a registered repository is registered and nothing more — that list carries no status and no project attachment, so it never proves work can be dispatched into it.
22
+
23
+ `catalyst-skills environment` is new, and it is the first setup action an agent can perform end to end. The tenant-wide environment declaration — the names your builds need — has been callable with your own login since the routes shipped, and nothing could call it, so every part of setting a tenant up was a page in the app. `environment` reads the current revision, whether it is approved, and which revision a phase's checkout actually carries (a proposal nobody approved changes nothing, and the verb says so); `environment propose --file <path>` proposes one from a JSON file or stdin, `--expect-revision` refuses if someone moved it since you read it, and `--approve` approves exactly the revision the propose returned. `environment approve` on its own reads the current revision and approves that, so the compare-and-set the cloud requires never depends on a hash copied between two commands. Reading needs any active seat; proposing and approving need an admin or owner one, and the cloud's refusal says which. The route paths come from your tenant's own contract and the derived ones are checked against it, so a cloud that does not serve them yet is named as older instead of answering 404. Repository-scope declarations are still a page. `catalyst-onboard`'s step for declaring what the containers need is now a command rather than a handover, and its report reads the declaration back.
24
+
25
+ The package description and the README no longer count the skills or list them in prose. A count written in a sentence cannot be kept true by anything, and the last one was wrong in the direction that made a correct install report itself as failed.
26
+
27
+ ## 0.5.0
28
+
29
+ The local replica can now keep the durable tenant event backbone beside its entity database. `catalyst-skills replica start` owns both synchronizers in one process, while `catalyst-skills events tail`, `events wait-for`, and `events query` read the bounded local cache with exact event-type and ticket filters. A failed event synchronizer is reported without stopping a healthy entity replica. This requires `@catalyst-cloud/sdk` 0.10.0 or newer.
30
+
31
+ You can release a stuck ticket yourself now. `catalyst-skills release <ticket> --because "<what changed>"` asks your tenant to release every park or hold on the ticket the right way for each one — after repeated failures, a spent repair-round cap, a repair that changed nothing, a validate failure that recurred — or to release nothing and name, for each thing it will not release, what a person has to do instead (close their own pull request, read a review that will not converge, answer the ask for a spent repair budget). `--dry-run` previews it and changes nothing; `--retry-unchanged` says something changed that the cloud cannot see, such as an outage that ended; `release --class <failure-class> --team <key>` does the same for every ticket on one team parked for the same reason. A refusal exits 1. Every release is recorded against your login, and `explain --history` now prints what holds the ticket and the ticket's past releases. The new `unstick` skill runs the whole loop — read why nothing runs, read the history, judge whether the cause is fixed, preview, release — and raises an ask only for what needs a person; the desk, the project owner and the failure references no longer say a park needs an operator. The eligibility explanation knows seven more reasons, including a parked phase and a person's own pull request. Needs a cloud that serves the release route (tenant contract 1.6.0); an older cloud says so.
32
+
33
+ Every skill's scripts now work for a machine connected with the keyless login. Since 0.4.0 a keyless `login` stores a login session and no key, and every skill script except the new `unstick` one read that as "not connected" and stopped before doing anything; only a personal key worked. The scripts now accept either, spawn the CLI, and let it authenticate and refresh the session as it always has. The check lives in one file vendored into every skill, so the next kind of credential lands once, and the not-connected line now names the keyless login first.
34
+
35
+ ## 0.4.1
36
+
37
+ `explain` now tells you when a whole team cannot start work. A team whose stages are not saved (no stage chosen for dispatch, PR, done and canceled, or a chosen stage since deleted in Linear) gets no dispatch queue at all, and `explain` used to answer a ticket in that team's Todo with "state Todo is not a dispatch state" — which sent people looking at the wrong setting. It now says the ticket cannot start because the team has no saved stage mapping, names the missing stages, and names the fix: a tenant owner or admin opens Settings → Linear teams, picks the team and presses Map my stages (or Adopt the Catalyst workflow). Needs a cloud that sends the team's dispatch gate; an older cloud prints what it did before. The reason table's wording for `workflow_mapping_unknown` and `ordering_never_published` no longer reads as a passing hiccup, since neither clears on its own for an unmapped team. The references now say plainly that `teams[].gitAutomation` in the contract is a stored consent for a feature that is not built — nothing reads it, so it never starts or stops work — where they used to say enabling it could delete a team's review automation. The catalyst-setup reference gains a section on setting up one team at a time as a pilot: what starts, what stays where it is, and that no other team's stages or tickets change.
38
+
3
39
  ## 0.4.0
4
40
 
5
41
  You can connect keyless now: run `catalyst-skills login` with no key and it opens a browser device-code login — it prints a short code and a URL, you approve in your browser (or from your phone on a machine with no browser), and this machine is connected as you, with nothing to mint or paste. The short-lived session token refreshes silently on every request and the config is rewritten atomically each time, so you stay connected for months and log in again only if the session is revoked or you have been away long enough to lapse — then one clear line tells you to run `login`. `customer.json` now holds exactly one of a login session (`auth`) or a personal key; `status` names which ("Credential: your login (expires …)" vs "Credential: personal key"). The personal key rail is unchanged and remains the fallback for scripts and unattended shells — `--key` or `CATALYST_CLOUD_TOKEN` — and keyless is the preferred rail everywhere the docs and the connect-me skill lead with it. Under the hood the auth layer is reworked around one credential provider (SDK 0.9): the replica and the live watch use the SDK's bearer auth strategy and resolve a fresh token per connect, so an OAuth session rotates underneath a long-running watch without a reconnect, and every HTTP read and write refreshes on the same path.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Or by hand. One command, for every coding agent on the machine:
18
18
  npx skills@latest add coalesce-labs/catalyst-cloud-skills --all
19
19
  ```
20
20
 
21
- It installs all eight skills for each agent it detects (Claude Code, Codex, Cursor, OpenCode and the rest). Add `-g` to install into your home directory instead of the project. Skills installed this way do not auto-update; run `npx skills update -y` to refresh them.
21
+ It installs every skill in the bundle for each agent it detects (Claude Code, Codex, Cursor, OpenCode and the rest). Add `-g` to install into your home directory instead of the project. Skills installed this way do not auto-update; run `npx skills update -y` to refresh them.
22
22
 
23
23
  <details><summary><strong>Alternative for Claude Code: the plugin marketplace</strong></summary>
24
24
 
@@ -77,7 +77,7 @@ That is the whole setup. Everything below explains what you just installed.
77
77
 
78
78
  ## What this is
79
79
 
80
- Eight skills that let your coding agent run your own Catalyst Cloud tenant (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your tenant through the Catalyst Cloud SDK, write to it through the tenant's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst-skills` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own login — a keyless device-code session, or a personal key — is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
80
+ A set of skills that let your coding agent run your own Catalyst Cloud tenant (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your tenant through the Catalyst Cloud SDK, write to it through the tenant's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst-skills` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own login — a keyless device-code session, or a personal key — is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
81
81
 
82
82
  ## What connecting does
83
83
 
@@ -112,17 +112,20 @@ Nothing, by default. After `login`, every read, write, ask and explanation goes
112
112
  | process | needed for | what it holds on disk | lifetime |
113
113
  | --- | --- | --- | --- |
114
114
  | none | every read, write, ask and explain | `customer.json` and `contract.json` | the default after login |
115
- | `catalyst-skills replica start` | local SQL, cheap repeated reads, `replica sql` and `replica schema` | one SQLite file (`~/.config/catalyst-cloud/replica.db`), a writer lock with a heartbeat beside it, and a cursor row inside the database | long-running; foreground by default, `--detach` writes a pidfile beside the database and returns; `login --start-replica` does the same at the end of login |
115
+ | `catalyst-skills replica start` | local SQL, cheap repeated reads, and local event `tail`, `wait-for`, and `query` | one SQLite file plus a bounded event cache under `$XDG_STATE_HOME/catalyst/events/` (fallback `~/.local/state/catalyst/events/`) | one long-running process; foreground by default, `--detach` writes a pidfile beside the database and returns; `login --start-replica` does the same at the end of login |
116
116
  | `catalyst-skills watch` | a project owner reacting to its scope | one cursor file (`~/.config/catalyst-cloud/watch-cursor.json`) stamped with the tenant | lives inside the session that armed it; exits with it |
117
117
 
118
118
  The check every skill runs first is `catalyst-skills replica status`, which needs no network: is the pidfile's process alive, is the writer-lock heartbeat younger than the staleness threshold, and is the cursor non-empty. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent, and prints one line either way (`--json` for scripts). A fresh replica is used; anything else falls back to the API and the skill says so in its answer. A skill never refuses to work because the replica is down and never silently reads a stale one. `replica status --probe` compares the local cursor against the cloud's head for the honest "how far behind" number; that is the only form that touches the network. `catalyst-skills replica stop` stops a detached writer.
119
119
 
120
- Nothing rotates. The replica is upserts and deletes into one file, the cursor is a row, and the watch cursor is a few bytes; there is no directory of old files to clean. It is a Node process, not a service: the supported path is the plain command, and `skills/connect-me/references/keeping-the-replica-running.md` gives launchd and systemd examples for people who want the writer to survive a reboot.
120
+ `catalyst-skills events tail` follows new cached events, `events wait-for --type ... --ticket ... --timeout ...` performs a bounded wait, and `events query` reads retained history. These commands never write the cache or contact the cloud. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per tenant and reports an explicit gap when a requested sequence has retired.
121
+
122
+ The replica is a Node process, not a service: the supported path is the plain command, and `skills/connect-me/references/keeping-the-replica-running.md` gives launchd and systemd examples for people who want the writer to survive a reboot.
121
123
 
122
124
  ## The skills
123
125
 
124
126
  | skill | what the person says | what it does | file |
125
127
  | --- | --- | --- | --- |
128
+ | `catalyst-onboard` | "Set me up. Onboard me. I just signed up — what do I do first?" | Walks you from nothing to your first ticket running, one step at a time: connect this machine, connect Linear, map one project, register one repository, then watch a card move. Reads each part of setup with the instrument that owns it and says who can fix anything unfinished and where. Hands over the steps only a browser can do instead of pretending to have done them. | [`SKILL.md`](skills/catalyst-onboard/SKILL.md) |
126
129
  | `whats-happening` | "What's happening? Where are we? Why is that stuck? What's next?" | The desk for a tenant: reads the contract, what is running and queued, the eligibility explainer and the open asks, and answers in one reply with ticket ids. Routes work to a project owner and decisions to `what-needs-me`. | [`SKILL.md`](skills/whats-happening/SKILL.md) |
127
130
  | `what-needs-me` | "What needs me? What am I blocking?" | The human's decision inbox, ranked by what each answer releases, and the one way an agent raises a decision on their behalf: files an ask through the cloud's ask route with the tenant's own template and records the answer so the held work releases. | [`SKILL.md`](skills/what-needs-me/SKILL.md) |
128
131
  | `run-this-project` | "Run this project for me. Own it until it closes." | Single-threaded owner of one project: subscribes to the tenant stream for its scope, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Never polls. | [`SKILL.md`](skills/run-this-project/SKILL.md) |
@@ -130,26 +133,27 @@ Nothing rotates. The replica is upserts and deletes into one file, the cursor is
130
133
  | `catalyst-linear` | "Show me the ticket, the history, what Catalyst wrote on it." | Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the replica when fresh and the API otherwise, always naming the source; writes comments, card moves, labels and new tickets as the app actor; knows what a ticket accumulates as Catalyst works it. | [`SKILL.md`](skills/catalyst-linear/SKILL.md) |
131
134
  | `catalyst-github` | "Show me the PR, the checks, the review, the queue." | A ticket's pull request with its checks, reviews and review threads; whether it is mergeable under the repository's policy; what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). | [`SKILL.md`](skills/catalyst-github/SKILL.md) |
132
135
  | `how-catalyst-works` | "How does this work? Why did it do that? How does it prioritise?" | The execution model as references loaded on demand: the eight-phase ladder, the eleven board slots and this team's live stage map, what happens when a phase fails, how the queue is ordered and routed, every exclusion reason, and the coding-account model. Scripts explain one ticket's eligibility in plain English. | [`SKILL.md`](skills/how-catalyst-works/SKILL.md) |
136
+ | `unstick` | "Why is this parked? Unpark it. Get things flowing again." | Reads why a ticket is not running and every park or hold on it, decides whether the recorded cause is fixed, previews the release and releases it the right way from your own login — or one failure class across a team — and raises an ask only for what a person has to do. | [`SKILL.md`](skills/unstick/SKILL.md) |
133
137
  | `connect-me` | "Connect this machine to my tenant." | Connects the machine to the tenant with your own personal key, caches the tenant contract, verifies, and offers to start the replica. | [`SKILL.md`](skills/connect-me/SKILL.md) |
134
138
 
135
- Four skills only read (`whats-happening`, `catalyst-setup`, `catalyst-github`, `how-catalyst-works`). The four that write anything (`catalyst-linear`, `what-needs-me`, `run-this-project`, `connect-me`) are marked so an agent cannot invoke them on its own; the person asks for them. Every skill declares `allowed-tools` scoped to this package's own binary, so none of them needs a blanket shell grant.
139
+ Some of these only read; the ones that write anything — or that drive you through writes, as `catalyst-onboard` does — are marked so an agent cannot invoke them on its own, and you ask for them by name. Each skill's own `SKILL.md` says which it is, in its frontmatter; no count lives in this sentence, because a count in a sentence goes wrong the first time the table above gains a row. Every skill declares `allowed-tools` scoped to this package's own binary, so none of them needs a blanket shell grant.
136
140
 
137
141
  ## What a key cannot see yet
138
142
 
139
- Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst-skills explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst-skills accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). Two things it cannot do, and the skills say so by name rather than guess:
143
+ Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst-skills explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst-skills accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst-skills release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst-skills environment` reads the tenant-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the app where you entered them — reading needs any active seat, proposing and approving need an admin or owner one. That is the account's own declaration — a repository's own `catalyst.env.json`, which `catalyst-setup` already reports on, is a separate thing. One thing it cannot do, and the skills say so by name rather than guess:
140
144
 
141
- - Release a park. When a ticket is parked after repeated failures, an operator releases it; the skill names the park and points at `<your cloud>/settings`.
142
145
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
143
146
 
144
147
  ## Versions and origins
145
148
 
146
- The package pins the tenant contract range `1.x`, recorded in `package.json` under `catalystCloud.tenantContractRange`, and reports it in `--version`, `status` and `login`. A tenant whose contract version falls outside that range is refused with one line naming both versions; update the bundle. Every skill carries a `vendored-from:` line naming this package as its origin; all eight are written in this repository for customer tenants.
149
+ The package pins the tenant contract range `1.x`, recorded in `package.json` under `catalystCloud.tenantContractRange`, and reports it in `--version`, `status` and `login`. A tenant whose contract version falls outside that range is refused with one line naming both versions; update the bundle. Every skill carries a `vendored-from:` line naming this package as its origin, and that line now also carries the version it was vendored at; all of them are written in this repository for customer tenants.
147
150
 
148
151
  ## What it writes on your machine
149
152
 
150
153
  - `~/.config/catalyst-cloud/customer.json`, written with mode `0600`, holding your personal key, who you are, and the CLI path.
151
154
  - `~/.config/catalyst-cloud/contract.json`, the cached tenant contract.
152
- - Only if you start them: `~/.config/catalyst-cloud/replica.db` with its `.pid` and `.writer.lock` sidecars, and `~/.config/catalyst-cloud/watch-cursor.json`.
155
+ - `~/.config/catalyst-cloud/published.json`, the cached answer to "what is the newest release" — no credential in it.
156
+ - Only if you start them: `~/.config/catalyst-cloud/replica.db` with its `.pid`, `.writer.lock` and `.writer.state` sidecars, `$XDG_STATE_HOME/catalyst/events/<tenant>/backbone/` (or the home-directory fallback) with bounded daily event segments, and `~/.config/catalyst-cloud/watch-cursor.json`.
153
157
 
154
158
  The skill files themselves are written by whichever install command you ran, in that tool's own location. Your personal key goes into that one config file and nowhere else.
155
159
 
@@ -163,14 +167,17 @@ A plugin install updates when we ship. Skills copied by `npx skills add` do not;
163
167
 
164
168
  A `customer.json` written by an older bundle is still read unchanged; it gains the CLI path and the cached contract the next time you run `catalyst-skills login`.
165
169
 
170
+ `catalyst-skills ready` also says when either the installed skills or the CLI is behind the latest publish, naming both versions and the command for each; it is a note, never a failure, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup.
171
+
166
172
  ## Uninstalling
167
173
 
168
- Remove the skills the way you installed them: `/plugin uninstall catalyst@catalyst-cloud` in Claude Code, or delete the eight directories (`catalyst-github`, `catalyst-linear`, `catalyst-setup`, `connect-me`, `how-catalyst-works`, `run-this-project`, `what-needs-me`, `whats-happening`) from wherever `npx skills add` wrote them. Then remove what the CLI wrote:
174
+ Remove the skills the way you installed them: `/plugin uninstall catalyst@catalyst-cloud` in Claude Code, or delete the skill directories (`catalyst-github`, `catalyst-linear`, `catalyst-onboard`, `catalyst-setup`, `connect-me`, `how-catalyst-works`, `run-this-project`, `unstick`, `what-needs-me`, `whats-happening`) from wherever `npx skills add` wrote them. Then remove what the CLI wrote:
169
175
 
170
176
  ```sh
171
177
  catalyst-skills replica stop
172
- rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/watch-cursor.json
173
- rm -f ~/.config/catalyst-cloud/replica.db ~/.config/catalyst-cloud/replica.db.pid ~/.config/catalyst-cloud/replica.db.writer.lock
178
+ rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/published.json ~/.config/catalyst-cloud/watch-cursor.json
179
+ rm -f ~/.config/catalyst-cloud/replica.db ~/.config/catalyst-cloud/replica.db.pid ~/.config/catalyst-cloud/replica.db.writer.lock ~/.config/catalyst-cloud/replica.db.writer.state
180
+ rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/catalyst/events"
174
181
  npm uninstall -g @catalyst-cloud/catalyst-skills
175
182
  ```
176
183
 
package/dist/args.js CHANGED
@@ -26,10 +26,11 @@ export const FLAG_TABLES = {
26
26
  team: { value: true, help: "team key filter" },
27
27
  project: { value: true, help: "project id filter" },
28
28
  state: { value: true, help: "state name filter" },
29
- limit: { value: true, help: "max rows (default 50)" },
29
+ limit: { value: true, help: "max rows (default 50); one page only unless --all" },
30
30
  since: { value: true, help: "changes: the cursor to read after" },
31
31
  source: { value: true, help: "replica | api (default: replica when fresh, else api)" },
32
32
  ticket: { value: true, help: "pulls: only PRs linked to this ticket" },
33
+ all: { value: false, help: "issues/pulls: follow the cloud's page cursor to the end of the scope" },
33
34
  },
34
35
  replica: {
35
36
  detach: { value: false, help: "start: run the writer in the background and write a pidfile" },
@@ -37,6 +38,14 @@ export const FLAG_TABLES = {
37
38
  probe: { value: false, help: "status: also fetch the cloud head and print the lag" },
38
39
  "stale-ms": { value: true, help: "status: heartbeat age that counts as stale (default 15000)" },
39
40
  },
41
+ events: {
42
+ type: { value: true, help: "exact event type" },
43
+ ticket: { value: true, help: "ticket identifier found in the event payload" },
44
+ after: { value: true, help: "event sequence to read after (tail/wait default to local head)" },
45
+ limit: { value: true, help: "query: maximum matching events (default 50)" },
46
+ timeout: { value: true, help: "wait-for: bounded wait in seconds (default 300)" },
47
+ directory: { value: true, help: "event cache directory (default: SDK XDG path)" },
48
+ },
40
49
  explain: {
41
50
  history: { value: false, help: "per-ticket execution history instead of the eligibility reason" },
42
51
  },
@@ -90,8 +99,26 @@ export const FLAG_TABLES = {
90
99
  answer: { value: true, help: "accept: the answering comment id" },
91
100
  role: { value: true, help: "accept: the role recording the answer" },
92
101
  },
93
- ready: {},
102
+ ready: {
103
+ offline: { value: false, help: "skip the published-release check (no network)" },
104
+ },
94
105
  accounts: {},
106
+ environment: {
107
+ file: { value: true, help: "propose: a JSON file holding the declaration" },
108
+ stdin: { value: false, help: "propose: read the declaration from stdin" },
109
+ "expect-revision": { value: true, help: "propose: refuse unless the stored declaration is still at this revision" },
110
+ approve: { value: false, help: "propose: approve exactly the revision the propose returned" },
111
+ revision: { value: true, help: "approve: the revision to approve (with --hash; default is whatever read returns)" },
112
+ hash: { value: true, help: "approve: the canonical hash to approve (with --revision)" },
113
+ },
114
+ release: {
115
+ because: { value: true, help: "what changed since the ticket was held (required unless --dry-run)" },
116
+ "retry-unchanged": { value: false, help: "release even though nothing the mirror can see changed (say what did in --because)" },
117
+ "dry-run": { value: false, help: "show what a release would clear and refuse, and change nothing" },
118
+ class: { value: true, help: "release every ticket on --team parked under this failure class" },
119
+ team: { value: true, help: "with --class: the team key" },
120
+ limit: { value: true, help: "with --class: at most this many tickets (the cloud caps it at 25)" },
121
+ },
95
122
  };
96
123
  export const VERB_USAGE = {
97
124
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
@@ -100,8 +127,9 @@ export const VERB_USAGE = {
100
127
  notice: "notice",
101
128
  me: "me [--json]",
102
129
  contract: "contract [--refresh] [--path <a.b.c>] [--json]",
103
- query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--source replica|api] [--json]",
130
+ query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--all] [--source replica|api] [--json]",
104
131
  replica: "replica <start [--detach]|stop|status [--probe] [--json]|sql \"<select>\"|schema [table]> [--db <path>]",
132
+ events: "events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
105
133
  explain: "explain <ticket> [--history] [--json]",
106
134
  history: "history <ticket> [--json]",
107
135
  running: "running [--ticket T --phase P] [--json]",
@@ -109,8 +137,10 @@ export const VERB_USAGE = {
109
137
  watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
110
138
  write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
111
139
  ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
112
- ready: "ready [--json]",
140
+ ready: "ready [--json] [--offline]",
113
141
  accounts: "accounts [--json]",
142
+ environment: "environment [read] [--json] | environment propose --file <path>|--stdin [--expect-revision N] [--approve] [--json] | environment approve [--revision N --hash H] [--json]",
143
+ release: "release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] [--json] | release --class <failure-class> --team <K> --because <what changed> [--retry-unchanged] [--dry-run] [--limit N] [--json]",
114
144
  };
115
145
  export function parseArgs(argv) {
116
146
  const out = {
package/dist/ask.js CHANGED
@@ -6,7 +6,7 @@ import { requireConfig } from "./config.js";
6
6
  import { loadContract, teamByKey } from "./contract.js";
7
7
  import { UsageError } from "./errors.js";
8
8
  import { apiClient } from "./transport.js";
9
- import { rowsOf } from "./query.js";
9
+ import { fetchAllPages } from "./pagination.js";
10
10
  import { fetchWorkflowStates, postAgent, resolveIssue } from "./write.js";
11
11
  export async function cmdAsk(args, ctx) {
12
12
  const [sub, ...rest] = positionals(args);
@@ -131,8 +131,11 @@ export function rankAsks(issues, doc, openState) {
131
131
  return out.sort((a, b) => b.score - a.score || a.identifier.localeCompare(b.identifier));
132
132
  }
133
133
  async function list(args, ctx, doc, api, cfg) {
134
- const [issuesRes, states] = await Promise.all([api.getJson("/api/v1/issues", { query: { limit: 500 } }), fetchWorkflowStates(api)]);
135
- const issues = rowsOf(issuesRes.body);
134
+ // ⛔ THE INBOX IS THE WHOLE SCOPE, NOT A PAGE. The mirror caps every page at 500 rows regardless of
135
+ // the requested limit, so a single `limit: 500` read drops every ask past the cap — and, worse,
136
+ // scores the asks it does show against a partial view of what they block (rankAsks below).
137
+ const [inbox, states] = await Promise.all([fetchAllPages(api, "/api/v1/issues", {}), fetchWorkflowStates(api)]);
138
+ const issues = inbox.rows;
136
139
  const terminal = new Set(states.filter((s) => ["completed", "canceled", "cancelled"].includes(s.type.toLowerCase())).map((s) => s.name.toLowerCase()));
137
140
  const openState = (issue) => !terminal.has(String(issue.state ?? "").toLowerCase());
138
141
  const all = rankAsks(issues, doc, openState);
package/dist/cli.js CHANGED
@@ -12,20 +12,25 @@ import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning } from "./exe
12
12
  import { cmdQuery } from "./query.js";
13
13
  import { cmdReady } from "./ready.js";
14
14
  import { cmdReplica } from "./replica.js";
15
+ import { cmdEvents } from "./events.js";
15
16
  import { stdinIsTty } from "./prompt.js";
16
- import { PROVENANCE_MARKER } from "./skill-shape.js";
17
- import { installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine } from "./skills.js";
17
+ import { FIRST_STAMPED_VERSION, PROVENANCE_MARKER, parseProvenanceVersion } from "./skill-shape.js";
18
+ import { installedBundleVersion, installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine, } from "./skills.js";
18
19
  import { cmdWatch } from "./watch.js";
19
20
  import { cmdWrite } from "./write.js";
20
21
  import { cmdAsk } from "./ask.js";
21
- export { CONFIG_MODE, DEFAULT_BASE_URL, PACKAGE_NAME, PROVENANCE_MARKER, CliError, MeError, UsageError, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, fetchMe, formatMode, installSkills, loadConfig, normalizeBaseUrl, parseArgs, parseChangelogEntry, readChangelog, readManifest, resolveSkillsDir, saveConfig, skillsSourceDir, updateNoticeLine, writeConfig, };
22
+ import { cmdRelease } from "./release.js";
23
+ import { cmdEnvironment } from "./environment.js";
24
+ export { CONFIG_MODE, DEFAULT_BASE_URL, FIRST_STAMPED_VERSION, PACKAGE_NAME, PROVENANCE_MARKER, CliError, MeError, UsageError, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, fetchMe, formatMode, installedBundleVersion, installSkills, loadConfig, normalizeBaseUrl, parseArgs, parseChangelogEntry, parseProvenanceVersion, readChangelog, readManifest, resolveSkillsDir, saveConfig, skillsSourceDir, updateNoticeLine, writeConfig, };
22
25
  export const CUSTOMER_SKILLS = [
23
26
  "catalyst-github",
24
27
  "catalyst-linear",
28
+ "catalyst-onboard",
25
29
  "catalyst-setup",
26
30
  "connect-me",
27
31
  "how-catalyst-works",
28
32
  "run-this-project",
33
+ "unstick",
29
34
  "what-needs-me",
30
35
  "whats-happening",
31
36
  ];
@@ -47,10 +52,13 @@ export function usageText() {
47
52
  " catalyst-skills contract [--refresh] [--path <a.b.c>]",
48
53
  " catalyst-skills query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
49
54
  " catalyst-skills replica <start [--detach]|stop|status [--probe]|sql \"<select>\"|schema [table]>",
55
+ " catalyst-skills events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE]",
50
56
  " catalyst-skills explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
51
57
  " catalyst-skills watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
52
58
  " catalyst-skills write <comment|state|label|create|reaction|attachment|session> ...",
53
59
  " catalyst-skills ask <raise|accept|list> ...",
60
+ " catalyst-skills release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
61
+ " catalyst-skills environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve",
54
62
  "",
55
63
  "Every verb takes --help. --json makes the output machine-readable.",
56
64
  "",
@@ -151,6 +159,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
151
159
  return await cmdQuery(args, ctx, { engineDeps: deps.replica?.engineDeps });
152
160
  case "replica":
153
161
  return await cmdReplica(args, ctx, deps.replica);
162
+ case "events":
163
+ return await cmdEvents(args, ctx, deps.events);
154
164
  case "explain":
155
165
  return await cmdExplain(args, ctx);
156
166
  case "history":
@@ -166,9 +176,13 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
166
176
  case "ask":
167
177
  return await cmdAsk(args, ctx);
168
178
  case "ready":
169
- return await cmdReady(args, ctx, { skillNames: CUSTOMER_SKILLS, loadSdk: deps.loadSdk });
179
+ return await cmdReady(args, ctx, { skillNames: CUSTOMER_SKILLS, loadSdk: deps.loadSdk, offline: args.flags.offline === true });
170
180
  case "accounts":
171
181
  return await cmdAccounts(args, ctx);
182
+ case "release":
183
+ return await cmdRelease(args, ctx);
184
+ case "environment":
185
+ return await cmdEnvironment(args, ctx, deps.environment ?? {});
172
186
  default:
173
187
  ctx.stderr(`unknown command: ${args.command}`);
174
188
  ctx.stderr(usageText());
@@ -192,7 +206,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
192
206
  throw err;
193
207
  }
194
208
  }
195
- const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts"].map((v) => [v, true]));
209
+ const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "events", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts", "release"].map((v) => [v, true]));
196
210
  async function cmdLogin(args, ctx, deps) {
197
211
  const manifest = readManifest();
198
212
  const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
package/dist/config.js CHANGED
@@ -27,6 +27,10 @@ export function configPathFor(home) {
27
27
  export function contractPathFor(home) {
28
28
  return join(configDirFor(home), "contract.json");
29
29
  }
30
+ /** CTC-2160 — the cached answer to "what is the newest published release" (src/published.ts). */
31
+ export function publishedCachePathFor(home) {
32
+ return join(configDirFor(home), "published.json");
33
+ }
30
34
  export function watchCursorPathFor(home) {
31
35
  return join(configDirFor(home), "watch-cursor.json");
32
36
  }
package/dist/contract.js CHANGED
@@ -121,6 +121,12 @@ export function teamByKey(doc, key) {
121
121
  }
122
122
  return team;
123
123
  }
124
+ /** The contract's team for a key, or null. `teamByKey` refuses; this one answers "is it there?" for
125
+ * readers (explain's cached dispatch gate) that must stay silent for a team the contract omits. */
126
+ export function findTeamByKey(doc, key) {
127
+ const wanted = key.toUpperCase();
128
+ return doc.teams.find((t) => (t.key ?? "").toUpperCase() === wanted) ?? null;
129
+ }
124
130
  export function teamById(doc, id) {
125
131
  return doc.teams.find((t) => t.id === id) ?? null;
126
132
  }
@@ -0,0 +1,207 @@
1
+ // environment.ts — `catalyst-skills environment`: the tenant-scope environment declaration, through
2
+ // the contract's account-environment routes. This is the ONE setup write a person's own key can
3
+ // perform: the cloud has admitted it since the routes shipped, and until this verb existed nothing
4
+ // could call it, so every setup action was a settings page.
5
+ //
6
+ // ⛔ THE ROUTE PATHS ARE DISCOVERED, NEVER ASSUMED. `read` is the route the contract's table names;
7
+ // `propose` and `approve` are derived from it the way the Worker derives them (one prefix, three
8
+ // verbs) and then CHECKED against the same table. A tenant whose cloud predates the routes gets the
9
+ // "needs a newer cloud" refusal by name rather than a 404 from a hand-copied path.
10
+ //
11
+ // ⭐ APPROVE IS A COMPARE-AND-SET on `(revision, canonicalHash)`, and with no flags this verb reads
12
+ // the current state and approves exactly what it just read. Hand-copying a hash between two commands
13
+ // is the failure that shape exists to prevent; making the caller do it anyway would reintroduce it.
14
+ import { readFileSync } from "node:fs";
15
+ import { flagBool, flagInt, flagString, positionals } from "./args.js";
16
+ import { requireConfig } from "./config.js";
17
+ import { loadContract } from "./contract.js";
18
+ import { UsageError } from "./errors.js";
19
+ import { needsNewerCloud } from "./execution.js";
20
+ import { apiClient } from "./transport.js";
21
+ /**
22
+ * The three account-environment paths this tenant serves.
23
+ *
24
+ * ⛔ `propose` and `approve` are NOT looked up by their own last path segment. `routePath`-style
25
+ * last-segment matching takes the FIRST route ending in that word, so the day a repo-scope
26
+ * `.../repo-environment/propose` joins the table, a lookup for "propose" could silently return it
27
+ * and this verb would write a repo's declaration into the account's name. Deriving both from the
28
+ * account prefix — and then asserting each derived path is really in the table — cannot do that.
29
+ */
30
+ export function environmentRoutes(doc, cfg) {
31
+ const base = doc.routes.find((r) => r.method === "GET" && r.path.split("/").filter(Boolean).at(-1) === "account-environment");
32
+ if (!base) {
33
+ throw needsNewerCloud('the tenant environment declaration (the contract serves no "account-environment" route)', cfg);
34
+ }
35
+ const routes = {
36
+ read: base.path,
37
+ propose: `${base.path}/propose`,
38
+ approve: `${base.path}/approve`,
39
+ };
40
+ for (const verb of ["propose", "approve"]) {
41
+ const served = doc.routes.some((r) => r.method === "POST" && r.path === routes[verb]);
42
+ if (!served) {
43
+ throw needsNewerCloud(`the tenant environment declaration's ${verb} route (${routes[verb]} is absent from the contract)`, cfg);
44
+ }
45
+ }
46
+ return routes;
47
+ }
48
+ /** The declaration to send: a JSON file, or stdin. Never invented, and never partially parsed. */
49
+ async function readDeclaration(args, readStdin) {
50
+ const file = flagString(args, "file");
51
+ const useStdin = flagBool(args, "stdin");
52
+ if (file !== undefined && useStdin)
53
+ throw new UsageError("environment propose takes --file <path> or --stdin, not both");
54
+ if (file === undefined && !useStdin) {
55
+ throw new UsageError("environment propose needs --file <path> or --stdin (the declaration is JSON; this verb never invents one)");
56
+ }
57
+ const text = file === undefined ? await readStdin() : readFileSync(file, "utf8");
58
+ if (text.trim() === "")
59
+ throw new UsageError(`the declaration ${file === undefined ? "on stdin" : `in ${file}`} is empty`);
60
+ try {
61
+ return JSON.parse(text);
62
+ }
63
+ catch (err) {
64
+ throw new UsageError(`the declaration ${file === undefined ? "on stdin" : `in ${file}`} is not JSON: ${err instanceof Error ? err.message : String(err)}`);
65
+ }
66
+ }
67
+ export function renderRead(r) {
68
+ const lines = [];
69
+ if (r.current === null || r.current === undefined) {
70
+ lines.push("no tenant environment declaration yet — nothing is delivered to a phase's checkout");
71
+ return lines;
72
+ }
73
+ lines.push(`revision ${r.current.revision} (${r.current.canonicalHash})${r.current.proposedBy ? `, proposed by ${r.current.proposedBy}` : ""}`);
74
+ lines.push(r.isApproved ? `approved${r.approvedBy ? ` by ${r.approvedBy}` : ""}` : "NOT approved — it is not delivered until someone approves it");
75
+ lines.push(r.delivered
76
+ ? `delivered to phases: revision ${r.delivered.revision} (${r.delivered.canonicalHash})`
77
+ : "delivered to phases: nothing yet");
78
+ // A name the declaration references that no tenant secret or variable carries RIGHT NOW. A warning,
79
+ // never a refusal: the secret may be created next, and a repo may supply the name at checkout.
80
+ const unresolved = r.unresolvedReferences ?? [];
81
+ if (unresolved.length > 0) {
82
+ lines.push(`referenced but not set on this tenant yet: ${unresolved.join(", ")} — add them in the app, or a phase that needs one will fail on it`);
83
+ }
84
+ return lines;
85
+ }
86
+ function renderState(state, verb) {
87
+ if (!state)
88
+ return [`${verb}, but the cloud returned no state`];
89
+ return [`${verb}: revision ${state.revision} (${state.canonicalHash})`];
90
+ }
91
+ async function readState(api, routes) {
92
+ const res = await api.getJson(routes.read);
93
+ return res.body;
94
+ }
95
+ async function approveExact(api, routes, revision, canonicalHash) {
96
+ const res = await api.postJson(routes.approve, { revision, canonicalHash }, { accept: [404, 409] });
97
+ return { body: res.body, status: res.status };
98
+ }
99
+ export async function cmdEnvironment(args, ctx, deps = {}) {
100
+ const [sub = "read", ...extra] = positionals(args);
101
+ if (extra.length > 0)
102
+ throw new UsageError(`environment takes one subcommand (got an extra "${extra[0]}")`);
103
+ if (sub !== "read" && sub !== "propose" && sub !== "approve") {
104
+ throw new UsageError(`unknown environment subcommand "${sub}": read | propose | approve`);
105
+ }
106
+ const cfg = requireConfig(ctx);
107
+ const { doc } = await loadContract(ctx, cfg);
108
+ const routes = environmentRoutes(doc, cfg);
109
+ const api = apiClient(cfg, ctx);
110
+ if (sub === "read") {
111
+ const body = await readState(api, routes);
112
+ if (args.json)
113
+ ctx.stdout(JSON.stringify(body));
114
+ else
115
+ for (const line of renderRead(body))
116
+ ctx.stdout(line);
117
+ return 0;
118
+ }
119
+ if (sub === "propose") {
120
+ const declaration = await readDeclaration(args, deps.readStdin ?? readStdin);
121
+ const expectedRevision = args.flags["expect-revision"] === undefined ? undefined : flagInt(args, "expect-revision", 0);
122
+ const res = await api.postJson(routes.propose, { declaration, ...(expectedRevision !== undefined ? { expectedRevision } : {}) }, { accept: [400, 404, 409] });
123
+ const body = res.body;
124
+ // A refusal is the cloud's own sentence, printed verbatim and exited 1 — never retried, and never
125
+ // smoothed into a success. `conflict` means someone else moved the declaration; re-read and redo.
126
+ if (res.status === 409 || res.status === 400 || res.status === 404) {
127
+ if (args.json)
128
+ ctx.stdout(JSON.stringify(body));
129
+ else {
130
+ ctx.stdout(`refused (${res.status}): ${body.message ?? body.error ?? "no reason given"}`);
131
+ if (body.reason)
132
+ ctx.stdout(` reason: ${body.reason}`);
133
+ if (body.currentRevision !== undefined) {
134
+ ctx.stdout(` the declaration is now at revision ${body.currentRevision} — run \`catalyst-skills environment read\` and propose again from that`);
135
+ }
136
+ }
137
+ return 1;
138
+ }
139
+ const approveToo = flagBool(args, "approve");
140
+ if (!args.json)
141
+ for (const line of renderState(body.state, body.status === "unchanged" ? "unchanged" : `${body.status ?? "proposed"}`))
142
+ ctx.stdout(line);
143
+ if (!approveToo) {
144
+ if (args.json)
145
+ ctx.stdout(JSON.stringify(body));
146
+ else if (body.status !== "unchanged")
147
+ ctx.stdout("not delivered until it is approved: catalyst-skills environment approve");
148
+ return 0;
149
+ }
150
+ // ⭐ Approve exactly what propose just returned. The whole point of the CAS is that the approver
151
+ // saw the revision they are approving, and nothing here has to be copied by a human or a model.
152
+ const state = body.state;
153
+ if (!state) {
154
+ ctx.stderr("the cloud returned no state to approve — run: catalyst-skills environment read");
155
+ return 1;
156
+ }
157
+ const approved = await approveExact(api, routes, state.revision, state.canonicalHash);
158
+ if (args.json) {
159
+ ctx.stdout(JSON.stringify({ propose: body, approve: approved.body }));
160
+ return approved.status === 200 ? 0 : 1;
161
+ }
162
+ return renderApprove(ctx, approved);
163
+ }
164
+ // approve — with no flags, read the current state and approve exactly that.
165
+ const revisionFlag = args.flags.revision === undefined ? undefined : flagInt(args, "revision", 0);
166
+ const hashFlag = flagString(args, "hash");
167
+ if ((revisionFlag === undefined) !== (hashFlag === undefined)) {
168
+ throw new UsageError("environment approve takes --revision and --hash together, or neither (it reads the current one)");
169
+ }
170
+ let revision = revisionFlag;
171
+ let hash = hashFlag;
172
+ if (revision === undefined || hash === undefined) {
173
+ const current = (await readState(api, routes)).current;
174
+ if (current === null || current === undefined) {
175
+ ctx.stdout("there is no declaration to approve — propose one first: catalyst-skills environment propose --file <path>");
176
+ return 1;
177
+ }
178
+ revision = current.revision;
179
+ hash = current.canonicalHash;
180
+ }
181
+ const approved = await approveExact(api, routes, revision, hash);
182
+ if (args.json) {
183
+ ctx.stdout(JSON.stringify(approved.body));
184
+ return approved.status === 200 ? 0 : 1;
185
+ }
186
+ return renderApprove(ctx, approved);
187
+ }
188
+ function renderApprove(ctx, approved) {
189
+ const { body, status } = approved;
190
+ if (status === 200 && body.approved) {
191
+ for (const line of renderState(body.state, "approved"))
192
+ ctx.stdout(line);
193
+ ctx.stdout("it is now what a phase's checkout carries");
194
+ return 0;
195
+ }
196
+ ctx.stdout(`refused (${status}): ${body.message ?? body.error ?? "no reason given"}`);
197
+ if (body.currentRevision !== undefined) {
198
+ ctx.stdout(` it is now at revision ${body.currentRevision}${body.currentHash ? ` (${body.currentHash})` : ""} — read it again before approving`);
199
+ }
200
+ return 1;
201
+ }
202
+ async function readStdin() {
203
+ const chunks = [];
204
+ for await (const chunk of process.stdin)
205
+ chunks.push(Buffer.from(chunk));
206
+ return Buffer.concat(chunks).toString("utf8");
207
+ }