@catalyst-cloud/catalyst-skills 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +13 -10
  3. package/dist/args.js +18 -0
  4. package/dist/cli.js +10 -1
  5. package/dist/events.js +106 -0
  6. package/dist/execution.js +54 -8
  7. package/dist/release.js +142 -0
  8. package/dist/replica.js +7 -1
  9. package/dist/transport.js +5 -2
  10. package/package.json +5 -4
  11. package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
  12. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  13. package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
  14. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  15. package/skills/catalyst-setup/SKILL.md +1 -1
  16. package/skills/catalyst-setup/references/what-each-check-means.md +5 -1
  17. package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
  18. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  19. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  20. package/skills/connect-me/scripts/lib/cli.mjs +3 -2
  21. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  22. package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
  23. package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
  24. package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
  25. package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
  26. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  27. package/skills/run-this-project/SKILL.md +1 -1
  28. package/skills/run-this-project/references/making-work-ready.md +1 -1
  29. package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
  30. package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
  31. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  32. package/skills/unstick/SKILL.md +41 -0
  33. package/skills/unstick/agents/openai.yaml +6 -0
  34. package/skills/unstick/agents/portability.yaml +5 -0
  35. package/skills/unstick/references/playbook.md +51 -0
  36. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  37. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  38. package/skills/unstick/scripts/unstick.mjs +57 -0
  39. package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
  40. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  41. package/skills/whats-happening/SKILL.md +1 -1
  42. package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
  43. package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
  44. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ 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.
10
+
11
+ ## 0.4.1
12
+
13
+ `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.
14
+
3
15
  ## 0.4.0
4
16
 
5
17
  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 all nine 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.
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
+ Nine 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,12 +112,14 @@ 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
 
@@ -130,26 +132,26 @@ Nothing rotates. The replica is upserts and deletes into one file, the cursor is
130
132
  | `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
133
  | `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
134
  | `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) |
135
+ | `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
136
  | `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
137
 
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.
138
+ Four skills only read (`whats-happening`, `catalyst-setup`, `catalyst-github`, `how-catalyst-works`). The five that write anything (`catalyst-linear`, `what-needs-me`, `run-this-project`, `connect-me`, `unstick`) 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.
136
139
 
137
140
  ## What a key cannot see yet
138
141
 
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:
142
+ 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. One thing it cannot do, and the skills say so by name rather than guess:
140
143
 
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
144
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
143
145
 
144
146
  ## Versions and origins
145
147
 
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.
148
+ 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 nine are written in this repository for customer tenants.
147
149
 
148
150
  ## What it writes on your machine
149
151
 
150
152
  - `~/.config/catalyst-cloud/customer.json`, written with mode `0600`, holding your personal key, who you are, and the CLI path.
151
153
  - `~/.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`.
154
+ - Only if you start them: `~/.config/catalyst-cloud/replica.db` with its `.pid` and `.writer.lock` 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
155
 
154
156
  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
157
 
@@ -165,12 +167,13 @@ A `customer.json` written by an older bundle is still read unchanged; it gains t
165
167
 
166
168
  ## Uninstalling
167
169
 
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:
170
+ Remove the skills the way you installed them: `/plugin uninstall catalyst@catalyst-cloud` in Claude Code, or delete the nine directories (`catalyst-github`, `catalyst-linear`, `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
171
 
170
172
  ```sh
171
173
  catalyst-skills replica stop
172
174
  rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/watch-cursor.json
173
175
  rm -f ~/.config/catalyst-cloud/replica.db ~/.config/catalyst-cloud/replica.db.pid ~/.config/catalyst-cloud/replica.db.writer.lock
176
+ rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/catalyst/events"
174
177
  npm uninstall -g @catalyst-cloud/catalyst-skills
175
178
  ```
176
179
 
package/dist/args.js CHANGED
@@ -37,6 +37,14 @@ export const FLAG_TABLES = {
37
37
  probe: { value: false, help: "status: also fetch the cloud head and print the lag" },
38
38
  "stale-ms": { value: true, help: "status: heartbeat age that counts as stale (default 15000)" },
39
39
  },
40
+ events: {
41
+ type: { value: true, help: "exact event type" },
42
+ ticket: { value: true, help: "ticket identifier found in the event payload" },
43
+ after: { value: true, help: "event sequence to read after (tail/wait default to local head)" },
44
+ limit: { value: true, help: "query: maximum matching events (default 50)" },
45
+ timeout: { value: true, help: "wait-for: bounded wait in seconds (default 300)" },
46
+ directory: { value: true, help: "event cache directory (default: SDK XDG path)" },
47
+ },
40
48
  explain: {
41
49
  history: { value: false, help: "per-ticket execution history instead of the eligibility reason" },
42
50
  },
@@ -92,6 +100,14 @@ export const FLAG_TABLES = {
92
100
  },
93
101
  ready: {},
94
102
  accounts: {},
103
+ release: {
104
+ because: { value: true, help: "what changed since the ticket was held (required unless --dry-run)" },
105
+ "retry-unchanged": { value: false, help: "release even though nothing the mirror can see changed (say what did in --because)" },
106
+ "dry-run": { value: false, help: "show what a release would clear and refuse, and change nothing" },
107
+ class: { value: true, help: "release every ticket on --team parked under this failure class" },
108
+ team: { value: true, help: "with --class: the team key" },
109
+ limit: { value: true, help: "with --class: at most this many tickets (the cloud caps it at 25)" },
110
+ },
95
111
  };
96
112
  export const VERB_USAGE = {
97
113
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
@@ -102,6 +118,7 @@ export const VERB_USAGE = {
102
118
  contract: "contract [--refresh] [--path <a.b.c>] [--json]",
103
119
  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]",
104
120
  replica: "replica <start [--detach]|stop|status [--probe] [--json]|sql \"<select>\"|schema [table]> [--db <path>]",
121
+ events: "events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
105
122
  explain: "explain <ticket> [--history] [--json]",
106
123
  history: "history <ticket> [--json]",
107
124
  running: "running [--ticket T --phase P] [--json]",
@@ -111,6 +128,7 @@ export const VERB_USAGE = {
111
128
  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
129
  ready: "ready [--json]",
113
130
  accounts: "accounts [--json]",
131
+ 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
132
  };
115
133
  export function parseArgs(argv) {
116
134
  const out = {
package/dist/cli.js CHANGED
@@ -12,12 +12,14 @@ 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
17
  import { PROVENANCE_MARKER } from "./skill-shape.js";
17
18
  import { 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";
22
+ import { cmdRelease } from "./release.js";
21
23
  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
24
  export const CUSTOMER_SKILLS = [
23
25
  "catalyst-github",
@@ -26,6 +28,7 @@ export const CUSTOMER_SKILLS = [
26
28
  "connect-me",
27
29
  "how-catalyst-works",
28
30
  "run-this-project",
31
+ "unstick",
29
32
  "what-needs-me",
30
33
  "whats-happening",
31
34
  ];
@@ -47,10 +50,12 @@ export function usageText() {
47
50
  " catalyst-skills contract [--refresh] [--path <a.b.c>]",
48
51
  " catalyst-skills query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
49
52
  " catalyst-skills replica <start [--detach]|stop|status [--probe]|sql \"<select>\"|schema [table]>",
53
+ " catalyst-skills events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE]",
50
54
  " catalyst-skills explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
51
55
  " catalyst-skills watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
52
56
  " catalyst-skills write <comment|state|label|create|reaction|attachment|session> ...",
53
57
  " catalyst-skills ask <raise|accept|list> ...",
58
+ " catalyst-skills release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
54
59
  "",
55
60
  "Every verb takes --help. --json makes the output machine-readable.",
56
61
  "",
@@ -151,6 +156,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
151
156
  return await cmdQuery(args, ctx, { engineDeps: deps.replica?.engineDeps });
152
157
  case "replica":
153
158
  return await cmdReplica(args, ctx, deps.replica);
159
+ case "events":
160
+ return await cmdEvents(args, ctx, deps.events);
154
161
  case "explain":
155
162
  return await cmdExplain(args, ctx);
156
163
  case "history":
@@ -169,6 +176,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
169
176
  return await cmdReady(args, ctx, { skillNames: CUSTOMER_SKILLS, loadSdk: deps.loadSdk });
170
177
  case "accounts":
171
178
  return await cmdAccounts(args, ctx);
179
+ case "release":
180
+ return await cmdRelease(args, ctx);
172
181
  default:
173
182
  ctx.stderr(`unknown command: ${args.command}`);
174
183
  ctx.stderr(usageText());
@@ -192,7 +201,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
192
201
  throw err;
193
202
  }
194
203
  }
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]));
204
+ 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
205
  async function cmdLogin(args, ctx, deps) {
197
206
  const manifest = readManifest();
198
207
  const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
package/dist/events.js ADDED
@@ -0,0 +1,106 @@
1
+ import { flagInt, flagString, positionals } from "./args.js";
2
+ import { apiBase, requireConfig } from "./config.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
+ import { authStrategyFor } from "./oauth.js";
5
+ const eventsModule = "@catalyst-cloud/sdk/events";
6
+ export const loadEventsSdk = () => import(eventsModule);
7
+ export async function createEventSync(ctx, deps = {}) {
8
+ const cfg = requireConfig(ctx);
9
+ const sdk = await (deps.loadSdk ?? loadEventsSdk)();
10
+ const auth = authStrategyFor(ctx, cfg);
11
+ if (auth.kind === "cookie")
12
+ throw new CliError("event sync requires a token or OAuth session", "events-auth");
13
+ return new sdk.CatalystEventSync({
14
+ baseUrl: apiBase(cfg),
15
+ auth,
16
+ tenantId: cfg.account,
17
+ fetch: ctx.fetch,
18
+ });
19
+ }
20
+ export async function cmdEvents(args, ctx, deps = {}) {
21
+ const [sub] = positionals(args);
22
+ if (!sub)
23
+ throw new UsageError("events needs a subcommand: tail | wait-for | query");
24
+ if (!["tail", "wait-for", "query"].includes(sub))
25
+ throw new UsageError(`unknown events subcommand: ${sub}`);
26
+ const cfg = requireConfig(ctx);
27
+ const sdk = await (deps.loadSdk ?? loadEventsSdk)();
28
+ const directory = flagString(args, "directory") ??
29
+ sdk.defaultEventCacheDirectory(cfg.account);
30
+ const after = startingCursor(args, sub === "query");
31
+ const matches = matcher(args);
32
+ if (sub === "query") {
33
+ const events = (await sdk.readCachedEvents({ tenantId: cfg.account, directory, after }))
34
+ .filter(matches)
35
+ .slice(-flagInt(args, "limit", 50));
36
+ for (const event of events)
37
+ ctx.stdout(JSON.stringify(event));
38
+ return 0;
39
+ }
40
+ const controller = new AbortController();
41
+ const abort = () => controller.abort(deps.signal?.reason);
42
+ if (deps.signal?.aborted)
43
+ abort();
44
+ else
45
+ deps.signal?.addEventListener("abort", abort, { once: true });
46
+ const timer = sub === "wait-for"
47
+ ? setTimeout(() => controller.abort(new Error("event wait timed out")), flagInt(args, "timeout", 300) * 1_000)
48
+ : undefined;
49
+ try {
50
+ for await (const event of sdk.tailCachedEvents({
51
+ tenantId: cfg.account,
52
+ directory,
53
+ after,
54
+ signal: controller.signal,
55
+ })) {
56
+ if (!matches(event))
57
+ continue;
58
+ ctx.stdout(JSON.stringify(event));
59
+ if (sub === "wait-for")
60
+ return 0;
61
+ }
62
+ return sub === "wait-for" ? 1 : 0;
63
+ }
64
+ catch (error) {
65
+ if (controller.signal.aborted)
66
+ return sub === "wait-for" ? 1 : 0;
67
+ if (error.code === "ENOENT")
68
+ throw new CliError(`event cache is absent at ${directory} — start it with: catalyst-skills replica start --detach`, "events-absent", 3);
69
+ throw error;
70
+ }
71
+ finally {
72
+ if (timer)
73
+ clearTimeout(timer);
74
+ deps.signal?.removeEventListener("abort", abort);
75
+ }
76
+ }
77
+ function startingCursor(args, history) {
78
+ const requested = flagString(args, "after");
79
+ if (requested !== undefined) {
80
+ const parsed = Number(requested);
81
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
82
+ throw new UsageError("--after must be a non-negative event sequence");
83
+ return parsed;
84
+ }
85
+ if (history)
86
+ return undefined;
87
+ return undefined;
88
+ }
89
+ function matcher(args) {
90
+ const type = flagString(args, "type");
91
+ const ticket = flagString(args, "ticket")?.toUpperCase();
92
+ return (event) => (!type || event.type === type) &&
93
+ (!ticket || payloadReferences(event.payload, ticket));
94
+ }
95
+ function payloadReferences(value, ticket) {
96
+ if (typeof value === "string")
97
+ return value.toUpperCase() === ticket;
98
+ if (Array.isArray(value))
99
+ return value.some((item) => payloadReferences(item, ticket));
100
+ if (!value || typeof value !== "object")
101
+ return false;
102
+ return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) &&
103
+ typeof item === "string"
104
+ ? item.toUpperCase() === ticket
105
+ : payloadReferences(item, ticket));
106
+ }
package/dist/execution.js CHANGED
@@ -37,13 +37,20 @@ export const EXCLUSION_REASONS = {
37
37
  retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
38
38
  routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
39
39
  repo_paused: "an operator paused the repository",
40
- remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired",
40
+ remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst-skills release <ticket>` releases the park once its cause is fixed",
41
+ phase_parked: "the offered phase is parked after repeated failures or the repair-round cap; `catalyst-skills release <ticket>` releases it once its cause is fixed",
42
+ later_phase_lease_held: "an earlier phase is offered while a live container still holds a later phase of this ticket",
43
+ human_owned_pr: "a person's own pull request holds this ticket; it releases itself when that PR closes or merges",
44
+ review_not_converging: "review and repair kept finding new problems without converging; a person reads the findings and comments on the ticket to resume",
45
+ round_threshold: "the ticket spent its lifetime repair budget; answering its ask or pushing a fix buys one more cycle",
46
+ claim_storm: "this unit was claimed too many times in the last hour, so it waits out the hour; nothing to release",
47
+ repo_at_capacity: "the repository's runner seats are all in use; it starts when one frees",
41
48
  };
42
49
  /** Every fail-closed unknown the evaluator names. */
43
50
  export const UNKNOWN_REASONS = {
44
- ordering_never_published: "the dispatch order has never been published for this team",
51
+ ordering_never_published: "the dispatch order has never been published for this team — usually because the team has no saved stage mapping, which does not clear by itself",
45
52
  ordering_stale: "the dispatch order is stale",
46
- workflow_mapping_unknown: "the team's workflow mapping could not be read",
53
+ workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself — a tenant owner or admin maps the team in Settings → Linear teams",
47
54
  ticket_unknown: "the ticket is not in the mirror",
48
55
  dependency_snapshot_unknown: "the dependency snapshot has never completed",
49
56
  blocker_unknown: "a blocking relation could not be resolved",
@@ -61,7 +68,20 @@ export function describeReason(reason) {
61
68
  return "no reason was given";
62
69
  return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
63
70
  }
64
- export function renderExplain(ticket, row, team, knownState) {
71
+ export function renderDispatchGate(ticket, team, gate) {
72
+ const slots = (gate.missingSlots ?? []).join(", ");
73
+ const why = gate.cause === "mapping_state_unresolved"
74
+ ? `the stage team ${team} mapped for ${slots} no longer exists in Linear.`
75
+ : gate.cause === "mapping_missing"
76
+ ? `team ${team} has no saved stage mapping for ${slots}.`
77
+ : `team ${team} cannot be dispatched (${gate.cause}).`;
78
+ return [`${ticket} cannot start: ${why}`, gate.remedy].filter(Boolean).join(" ");
79
+ }
80
+ export function renderExplain(ticket, row, team, knownState, gate) {
81
+ // An unmapped team has no queue and so no rows; its gate is the whole answer, and it outranks the
82
+ // Backlog probe below, which would otherwise call the team's own Todo "not a dispatch state".
83
+ if (gate)
84
+ return renderDispatchGate(ticket, team, gate);
65
85
  if (!row) {
66
86
  // A null dispatch row is not proof the ticket does not exist — the explainer is a dispatch scan. When
67
87
  // `knownState` is set (the mirror answered GET /issues/:id) the ticket is real; it just sits outside a
@@ -130,11 +150,15 @@ export async function cmdExplain(args, ctx) {
130
150
  if (probe.status !== 404)
131
151
  knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
132
152
  }
153
+ // The gate is TEAM-wide: it can say why a real ticket in that team cannot start, never that this id
154
+ // exists. Applied only when the mirror probe above found the ticket; a 404 keeps the unknown wording.
155
+ const gate = !row && knownState ? (res.body.eligibility?.dispatchGate ?? null) : null;
156
+ const explanation = renderExplain(ticket, row, team, knownState, gate);
133
157
  if (args.json) {
134
- ctx.stdout(JSON.stringify({ ticket, row, explanation: renderExplain(ticket, row, team, knownState) }));
158
+ ctx.stdout(JSON.stringify({ ticket, row, ...(gate ? { dispatchGate: gate } : {}), explanation }));
135
159
  }
136
160
  else {
137
- ctx.stdout(renderExplain(ticket, row, team, knownState));
161
+ ctx.stdout(explanation);
138
162
  }
139
163
  return 0;
140
164
  }
@@ -237,7 +261,7 @@ export function renderAccount(a) {
237
261
  * ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
238
262
  * which is a different fact from "you have no coding accounts" and must never print as the latter.
239
263
  */
240
- function needsNewerCloud(what, cfg) {
264
+ export function needsNewerCloud(what, cfg) {
241
265
  return new CliError(`${what} needs a newer Catalyst Cloud than ${normalizeBaseUrl(cfg.baseUrl)} is running — the route answered 404. Nothing is wrong with your tenant; ask your operator when the mirror last deployed.`, "route-not-deployed", 3, 404);
242
266
  }
243
267
  export async function cmdAccounts(args, ctx) {
@@ -310,7 +334,29 @@ export function renderHistory(ticket, doc) {
310
334
  lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
311
335
  }
312
336
  if (doc.park) {
313
- lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "needs an operator"} — ${doc.park.releasedBy}`);
337
+ lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "does not release itself"} — ${doc.park.releasedBy}`);
338
+ }
339
+ // Absent (an older cloud) prints nothing; `null` is a table that could not be read.
340
+ if (doc.governors === null) {
341
+ lines.push("Held by: could not be read (unreadable, not empty)");
342
+ }
343
+ else if (doc.governors !== undefined && doc.governors.length > 0) {
344
+ lines.push("Held by:");
345
+ for (const g of doc.governors) {
346
+ lines.push(` ${g.kind}${g.phase ? ` at ${g.phase}` : ""}${g.sentinel ? ` (${g.sentinel})` : ""} — ${g.release ?? "no release named"}`);
347
+ }
348
+ }
349
+ if (doc.releases === null) {
350
+ lines.push("Releases: could not be read (unreadable, not empty)");
351
+ }
352
+ else if (doc.releases !== undefined && doc.releases.length > 0) {
353
+ lines.push("Releases (newest first):");
354
+ for (const r of doc.releases) {
355
+ const what = r.outcome === "released"
356
+ ? `released: ${(r.released ?? []).map((a) => `${a.op}${a.phase ? ` ${a.phase}` : ""}`).join(", ")}`
357
+ : `refused: ${(r.refused ?? []).map((x) => x.code).join(", ")}`;
358
+ lines.push(` ${new Date(r.atMs).toISOString()} ${r.outcome} by ${r.actor.kind}:${r.actor.id} — "${r.because}" — ${what}`);
359
+ }
314
360
  }
315
361
  for (const l of doc.lease ?? [])
316
362
  lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
@@ -0,0 +1,142 @@
1
+ // release.ts — `catalyst-skills release`: a person's release of a parked or held ticket, or of every
2
+ // ticket on one team parked under one failure class, through the contract's `ticket-release` and
3
+ // `ticket-release-class` routes. The cloud reads every governor holding the ticket and either releases
4
+ // all of them or releases nothing and names, per governor, the human action that does fix it; this verb
5
+ // prints that answer and exits 1 on a refusal so a script can branch on it.
6
+ //
7
+ // ⛔ NOTHING IS RELEASED WITHOUT A REASON. `--because` (what changed) is required on a real release and
8
+ // is recorded in the tenant's release audit; only `--dry-run` may omit it. `--retry-unchanged` is the
9
+ // explicit statement that something the mirror cannot see changed — the cloud refuses an unchanged
10
+ // cause without it.
11
+ import { flagBool, flagString, positionals } from "./args.js";
12
+ import { requireConfig } from "./config.js";
13
+ import { loadContract } from "./contract.js";
14
+ import { CliError, UsageError } from "./errors.js";
15
+ import { needsNewerCloud } from "./execution.js";
16
+ import { apiClient } from "./transport.js";
17
+ /** One release written as a person reads it: "unparked implement", "cleared the validate hold". */
18
+ export function describeAction(a) {
19
+ switch (a.op) {
20
+ case "unpark":
21
+ return `unparked ${a.phase ?? "a phase"}`;
22
+ case "clear_validate_hold":
23
+ return "cleared the validate hold";
24
+ case "clear_no_change_hold":
25
+ return "cleared the no-change hold";
26
+ case "grant_round_threshold_cycle":
27
+ return "bought one more repair cycle";
28
+ default:
29
+ return `${a.op}${a.phase ? ` at ${a.phase}` : ""}`;
30
+ }
31
+ }
32
+ const refusalLine = (r) => ` ${r.code}: ${r.humanAction}`;
33
+ export function renderTicketRelease(ticket, r) {
34
+ if (r.error === "team_changed") {
35
+ return [`${ticket} moved from team ${r.expectedTeamKey ?? "(none)"} to ${r.teamKey ?? "(none)"} while the release was being checked — run it again`];
36
+ }
37
+ const lines = [];
38
+ const dry = r.dryRun === true;
39
+ if (r.outcome === "nothing-held") {
40
+ lines.push(`${ticket}: nothing holds this ticket — run \`catalyst-skills explain ${ticket}\` for why it is not running`);
41
+ }
42
+ else if (r.outcome === "refused") {
43
+ lines.push(dry ? `${ticket} (dry run): would be refused — nothing would be released:` : `${ticket}: refused — nothing was released:`);
44
+ for (const x of r.refused ?? [])
45
+ lines.push(refusalLine(x));
46
+ const blocked = r.releasable ?? [];
47
+ if (blocked.length > 0)
48
+ lines.push(` (it would otherwise have ${blocked.map(describeAction).join(", ")})`);
49
+ }
50
+ else if (dry) {
51
+ lines.push(`${ticket} (dry run): would release — ${(r.releasable ?? []).map(describeAction).join(", ")}`);
52
+ }
53
+ else {
54
+ lines.push(`${ticket}: released — ${(r.released ?? []).map(describeAction).join(", ")}`);
55
+ }
56
+ for (const w of r.warnings ?? [])
57
+ lines.push(`warning: ${w}`);
58
+ return lines;
59
+ }
60
+ export function renderClassRelease(r) {
61
+ const released = r.released ?? [];
62
+ const refused = r.refused ?? [];
63
+ const nothing = r.nothingHeld ?? [];
64
+ const head = `${r.team ?? "?"} ${r.class ?? "?"}${r.dryRun ? " (dry run)" : ""}: released ${released.length}, refused ${refused.length}, nothing held ${nothing.length}${r.truncated ? " (more remain — run it again)" : ""}`;
65
+ const lines = [head];
66
+ for (const t of released) {
67
+ const actions = r.dryRun ? (t.releasable ?? []) : (t.released ?? []);
68
+ lines.push(` ${r.dryRun ? "would release" : "released"} ${t.ticket ?? "?"}: ${actions.map(describeAction).join(", ")}`);
69
+ }
70
+ for (const t of refused) {
71
+ for (const x of t.refused ?? [])
72
+ lines.push(` refused ${t.ticket ?? "?"} — ${x.code}: ${x.humanAction}`);
73
+ }
74
+ if (nothing.length > 0)
75
+ lines.push(` nothing held: ${nothing.join(", ")}`);
76
+ return lines;
77
+ }
78
+ /** The contract's route by name, or the "your cloud is older" error when this tenant does not serve it. */
79
+ function releaseRoute(doc, name, cfg) {
80
+ const route = doc.routes.find((r) => r.method === "POST" && r.path.split("/").filter(Boolean).at(-1) === name);
81
+ if (!route)
82
+ throw needsNewerCloud(`release (the tenant contract serves no "${name}" route)`, cfg);
83
+ return route.path;
84
+ }
85
+ export async function cmdRelease(args, ctx) {
86
+ const [ticket, ...extra] = positionals(args);
87
+ const failureClass = flagString(args, "class");
88
+ const team = flagString(args, "team");
89
+ const because = flagString(args, "because");
90
+ const dryRun = flagBool(args, "dry-run");
91
+ const retryUnchanged = flagBool(args, "retry-unchanged");
92
+ if (extra.length > 0)
93
+ throw new UsageError("release takes one ticket");
94
+ if (failureClass !== undefined && ticket !== undefined) {
95
+ throw new UsageError("release takes a ticket or --class with --team, not both");
96
+ }
97
+ if (failureClass === undefined && ticket === undefined) {
98
+ throw new UsageError("release needs a ticket, or --class <failure-class> --team <K>");
99
+ }
100
+ if (failureClass !== undefined && team === undefined)
101
+ throw new UsageError("release --class needs --team <K>");
102
+ if (because === undefined && !dryRun) {
103
+ throw new UsageError("release needs --because <what changed> (recorded in the release audit), or --dry-run to preview");
104
+ }
105
+ const limitRaw = flagString(args, "limit");
106
+ const limit = limitRaw === undefined ? undefined : Number(limitRaw);
107
+ if (limit !== undefined && !Number.isInteger(limit))
108
+ throw new UsageError("--limit must be an integer");
109
+ const cfg = requireConfig(ctx);
110
+ const { doc } = await loadContract(ctx, cfg);
111
+ const api = apiClient(cfg, ctx);
112
+ const common = { ...(because !== undefined ? { because } : {}), retryUnchanged, dryRun };
113
+ if (failureClass !== undefined) {
114
+ const path = releaseRoute(doc, "ticket-release-class", cfg);
115
+ const res = await api.postJson(path, {
116
+ team,
117
+ class: failureClass,
118
+ ...common,
119
+ ...(limit !== undefined ? { limit } : {}),
120
+ });
121
+ if (args.json)
122
+ ctx.stdout(JSON.stringify(res.body));
123
+ else
124
+ for (const line of renderClassRelease(res.body))
125
+ ctx.stdout(line);
126
+ // A real class release that refused any ticket exits 1, the same contract as a single refusal.
127
+ return res.body.dryRun !== true && (res.body.refused ?? []).length > 0 ? 1 : 0;
128
+ }
129
+ const id = ticket;
130
+ const path = releaseRoute(doc, "ticket-release", cfg);
131
+ const res = await api.postJson(path, { ticket: id, ...common }, { accept: [404, 409] });
132
+ if (res.status === 404) {
133
+ throw new CliError(`ticket ${id} is not in this tenant's mirror — check the identifier`, "ticket-unknown");
134
+ }
135
+ if (args.json)
136
+ ctx.stdout(JSON.stringify(res.body));
137
+ else
138
+ for (const line of renderTicketRelease(id, res.body))
139
+ ctx.stdout(line);
140
+ const refused = res.status === 409 || (res.body.outcome === "refused" && res.body.dryRun !== true);
141
+ return refused ? 1 : 0;
142
+ }
package/dist/replica.js CHANGED
@@ -13,6 +13,7 @@ import { CliError, UsageError } from "./errors.js";
13
13
  import { apiClient } from "./transport.js";
14
14
  import { authStrategyFor } from "./oauth.js";
15
15
  import { loadSdk } from "./sdk.js";
16
+ import { createEventSync } from "./events.js";
16
17
  export const DEFAULT_STALE_MS = 15_000;
17
18
  export function pidfilePath(dbPath) {
18
19
  return `${dbPath}.pid`;
@@ -214,7 +215,11 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
214
215
  onStatus: (s) => ctx.stderr(`[replica] status=${s}`),
215
216
  });
216
217
  await replica.start();
217
- ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}) — Ctrl-C to stop`);
218
+ const eventSync = await createEventSync(ctx, { loadSdk: deps.loadEventsSdk });
219
+ void eventSync.start().catch((error) => {
220
+ ctx.stderr(`[events] sync failed: ${error instanceof Error ? error.message : String(error)}; replica remains live`);
221
+ });
222
+ ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}); event cache active — Ctrl-C to stop`);
218
223
  const wait = deps.waitForStop ??
219
224
  (() => new Promise((resolve) => {
220
225
  const done = () => resolve();
@@ -222,6 +227,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
222
227
  process.once("SIGTERM", done);
223
228
  }));
224
229
  await wait();
230
+ await eventSync.stop();
225
231
  await replica.close();
226
232
  try {
227
233
  if (readPidfile(dbPath) === process.pid)
package/dist/transport.js CHANGED
@@ -70,7 +70,9 @@ export class ApiClient {
70
70
  }
71
71
  return { status: res.status, body: rows, headers: res.headers };
72
72
  }
73
- async postJson(path, body) {
73
+ /** `opts.accept` lists non-2xx statuses returned to the caller with their JSON body instead of thrown
74
+ * — the release route answers a refusal as 409 with the refusals the caller has to print. */
75
+ async postJson(path, body, opts = {}) {
74
76
  const url = this.url(path);
75
77
  const res = await this.send(url, {
76
78
  method: "POST",
@@ -81,7 +83,8 @@ export class ApiClient {
81
83
  },
82
84
  body: JSON.stringify(body),
83
85
  });
84
- await this.refuseIfNotOk(res, `POST ${path}`);
86
+ if (!opts.accept?.includes(res.status))
87
+ await this.refuseIfNotOk(res, `POST ${path}`);
85
88
  return { status: res.status, body: await this.parseBody(res, `POST ${path}`), headers: res.headers };
86
89
  }
87
90
  async send(url, init) {