@catalyst-cloud/catalyst-skills 0.5.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 (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +13 -9
  3. package/dist/args.js +16 -4
  4. package/dist/ask.js +6 -3
  5. package/dist/cli.js +9 -4
  6. package/dist/config.js +4 -0
  7. package/dist/contract.js +6 -0
  8. package/dist/environment.js +207 -0
  9. package/dist/execution.js +97 -15
  10. package/dist/pagination.js +76 -0
  11. package/dist/published.js +75 -0
  12. package/dist/query.js +41 -18
  13. package/dist/ready.js +110 -19
  14. package/dist/replica.js +295 -35
  15. package/dist/semver.js +18 -0
  16. package/dist/skill-shape.js +10 -0
  17. package/dist/skills.js +52 -1
  18. package/dist/ts-deps-loader.js +46 -1
  19. package/package.json +5 -3
  20. package/skills/catalyst-github/SKILL.md +1 -1
  21. package/skills/catalyst-linear/SKILL.md +1 -1
  22. package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
  23. package/skills/catalyst-onboard/SKILL.md +49 -0
  24. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  25. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  26. package/skills/catalyst-onboard/references/the-one-path.md +143 -0
  27. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  28. package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
  29. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  30. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  31. package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
  32. package/skills/catalyst-setup/SKILL.md +2 -2
  33. package/skills/catalyst-setup/references/what-each-check-means.md +17 -5
  34. package/skills/connect-me/SKILL.md +1 -1
  35. package/skills/how-catalyst-works/SKILL.md +1 -1
  36. package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
  37. package/skills/run-this-project/SKILL.md +1 -1
  38. package/skills/unstick/SKILL.md +1 -1
  39. package/skills/what-needs-me/SKILL.md +1 -1
  40. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  41. package/skills/whats-happening/SKILL.md +1 -1
  42. package/skills/whats-happening/scripts/snapshot.mjs +4 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
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
+
3
27
  ## 0.5.0
4
28
 
5
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.
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 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.
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
- 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.
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
 
@@ -125,6 +125,7 @@ The replica is a Node process, not a service: the supported path is the plain co
125
125
 
126
126
  | skill | what the person says | what it does | file |
127
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) |
128
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) |
129
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) |
130
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) |
@@ -135,23 +136,24 @@ The replica is a Node process, not a service: the supported path is the plain co
135
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) |
136
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) |
137
138
 
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.
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.
139
140
 
140
141
  ## What a key cannot see yet
141
142
 
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:
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:
143
144
 
144
145
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
145
146
 
146
147
  ## Versions and origins
147
148
 
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.
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.
149
150
 
150
151
  ## What it writes on your machine
151
152
 
152
153
  - `~/.config/catalyst-cloud/customer.json`, written with mode `0600`, holding your personal key, who you are, and the CLI path.
153
154
  - `~/.config/catalyst-cloud/contract.json`, the cached tenant contract.
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`.
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`.
155
157
 
156
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.
157
159
 
@@ -165,14 +167,16 @@ A plugin install updates when we ship. Skills copied by `npx skills add` do not;
165
167
 
166
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`.
167
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
+
168
172
  ## Uninstalling
169
173
 
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:
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:
171
175
 
172
176
  ```sh
173
177
  catalyst-skills replica stop
174
- rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/watch-cursor.json
175
- 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
176
180
  rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/catalyst/events"
177
181
  npm uninstall -g @catalyst-cloud/catalyst-skills
178
182
  ```
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" },
@@ -98,8 +99,18 @@ export const FLAG_TABLES = {
98
99
  answer: { value: true, help: "accept: the answering comment id" },
99
100
  role: { value: true, help: "accept: the role recording the answer" },
100
101
  },
101
- ready: {},
102
+ ready: {
103
+ offline: { value: false, help: "skip the published-release check (no network)" },
104
+ },
102
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
+ },
103
114
  release: {
104
115
  because: { value: true, help: "what changed since the ticket was held (required unless --dry-run)" },
105
116
  "retry-unchanged": { value: false, help: "release even though nothing the mirror can see changed (say what did in --because)" },
@@ -116,7 +127,7 @@ export const VERB_USAGE = {
116
127
  notice: "notice",
117
128
  me: "me [--json]",
118
129
  contract: "contract [--refresh] [--path <a.b.c>] [--json]",
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]",
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]",
120
131
  replica: "replica <start [--detach]|stop|status [--probe] [--json]|sql \"<select>\"|schema [table]> [--db <path>]",
121
132
  events: "events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
122
133
  explain: "explain <ticket> [--history] [--json]",
@@ -126,8 +137,9 @@ export const VERB_USAGE = {
126
137
  watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
127
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]>",
128
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]>",
129
- ready: "ready [--json]",
140
+ ready: "ready [--json] [--offline]",
130
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]",
131
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]",
132
144
  };
133
145
  export function parseArgs(argv) {
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
@@ -14,16 +14,18 @@ import { cmdReady } from "./ready.js";
14
14
  import { cmdReplica } from "./replica.js";
15
15
  import { cmdEvents } from "./events.js";
16
16
  import { stdinIsTty } from "./prompt.js";
17
- import { PROVENANCE_MARKER } from "./skill-shape.js";
18
- 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";
19
19
  import { cmdWatch } from "./watch.js";
20
20
  import { cmdWrite } from "./write.js";
21
21
  import { cmdAsk } from "./ask.js";
22
22
  import { cmdRelease } from "./release.js";
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, };
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, };
24
25
  export const CUSTOMER_SKILLS = [
25
26
  "catalyst-github",
26
27
  "catalyst-linear",
28
+ "catalyst-onboard",
27
29
  "catalyst-setup",
28
30
  "connect-me",
29
31
  "how-catalyst-works",
@@ -56,6 +58,7 @@ export function usageText() {
56
58
  " catalyst-skills write <comment|state|label|create|reaction|attachment|session> ...",
57
59
  " catalyst-skills ask <raise|accept|list> ...",
58
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",
59
62
  "",
60
63
  "Every verb takes --help. --json makes the output machine-readable.",
61
64
  "",
@@ -173,11 +176,13 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
173
176
  case "ask":
174
177
  return await cmdAsk(args, ctx);
175
178
  case "ready":
176
- 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 });
177
180
  case "accounts":
178
181
  return await cmdAccounts(args, ctx);
179
182
  case "release":
180
183
  return await cmdRelease(args, ctx);
184
+ case "environment":
185
+ return await cmdEnvironment(args, ctx, deps.environment ?? {});
181
186
  default:
182
187
  ctx.stderr(`unknown command: ${args.command}`);
183
188
  ctx.stderr(usageText());
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
+ }
package/dist/execution.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // exclusion reason and unknown the cloud names; an unlisted reason prints its raw string, never nothing.
4
4
  import { flagBool, flagString, positionals } from "./args.js";
5
5
  import { normalizeBaseUrl, requireConfig } from "./config.js";
6
- import { loadContract } from "./contract.js";
7
- import { CliError, UsageError } from "./errors.js";
6
+ import { findTeamByKey, loadContract } from "./contract.js";
7
+ import { CliError, MeError, UsageError } from "./errors.js";
8
8
  import { apiClient } from "./transport.js";
9
9
  /** Every exclusion reason the eligibility evaluator names, in plain English. */
10
10
  export const EXCLUSION_REASONS = {
@@ -68,6 +68,27 @@ export function describeReason(reason) {
68
68
  return "no reason was given";
69
69
  return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
70
70
  }
71
+ /** The contract spells the same concept `status`; normalize onto the `cause` the renderer uses.
72
+ * `"open"` and an absent/shapeless gate both mean "nothing to report" — null. An unrecognized
73
+ * status is passed through as `cause`, never swallowed (D2/D3 — CTC-2208). */
74
+ export function gateFromContract(g) {
75
+ if (!g || typeof g.status !== "string")
76
+ return null;
77
+ if (g.status === "open")
78
+ return null;
79
+ return { cause: g.status, missingSlots: g.missingSlots, remedy: g.remedy, source: "contract" };
80
+ }
81
+ /** True when the live eligibility read failed in a way a cached gate may stand in for: the read was
82
+ * UNAVAILABLE (network/timeout/non-JSON, or a 404/5xx). A cloud that ANSWERED — 401/403/400 — is
83
+ * refusing, not silent, and a cached gate must never hide that refusal behind an unrelated mapping
84
+ * story (D4 — CTC-2208). */
85
+ export function liveReadMayFallBack(err) {
86
+ if (err instanceof MeError)
87
+ return true;
88
+ if (err instanceof CliError)
89
+ return err.status === undefined || err.status === 404 || err.status >= 500;
90
+ return false;
91
+ }
71
92
  export function renderDispatchGate(ticket, team, gate) {
72
93
  const slots = (gate.missingSlots ?? []).join(", ");
73
94
  const why = gate.cause === "mapping_state_unresolved"
@@ -136,26 +157,87 @@ export async function cmdExplain(args, ctx) {
136
157
  throw new UsageError(`"${ticket}" is not a ticket identifier (expected KEY-123)`);
137
158
  const team = ticket.slice(0, dash).toUpperCase();
138
159
  const api = apiClient(cfg, ctx);
139
- const res = await api.getJson("/api/v1/work-eligibility", {
140
- query: { team, capabilities: doc.ladder.phases.join(",") },
141
- });
142
- const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
143
- const row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
144
- // No dispatch row is not proof of non-existence: probe the mirror so a Backlog ticket reads as known.
145
- // The mirror compares identifiers exactly, so normalize (uppercase) as the row lookup above does —
146
- // otherwise `explain eng-7` probes a lowercase id, 404s, and reports an existing ENG-7 as unknown.
160
+ // The cached contract's own gate for this team. Read BEFORE the live call so it can stand in when
161
+ // that call cannot be made at all. A team absent from the contract simply has none.
162
+ const cachedGate = gateFromContract(findTeamByKey(doc, team)?.dispatchGate);
163
+ let row = null;
147
164
  let knownState = null;
165
+ let liveGate = null;
166
+ let liveFailure = null;
167
+ let liveGateRaw = null;
168
+ // This `try` wraps the eligibility read ALONE. It used to span the mirror probe below as well,
169
+ // which broke both reads at once: a probe outage was reported as "the live eligibility read was
170
+ // unavailable", and an eligibility outage skipped the probe entirely so `knownState` stayed null
171
+ // and a mistyped id inherited the team-wide gate — the very bug 4defc34 was written to fix.
172
+ try {
173
+ const res = await api.getJson("/api/v1/work-eligibility", { query: { team, capabilities: doc.ladder.phases.join(",") } });
174
+ const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
175
+ row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
176
+ liveGateRaw = res.body.eligibility?.dispatchGate ?? null;
177
+ }
178
+ catch (err) {
179
+ // A cached gate is an answer the live read cannot give right now — but ONLY for a failure that
180
+ // means "unavailable". A 401/403/400 is the cloud answering, and must still refuse (CTC-2208 D4).
181
+ if (!cachedGate || !liveReadMayFallBack(err))
182
+ throw err;
183
+ liveFailure = err instanceof Error ? err : new Error(String(err));
184
+ }
185
+ // No dispatch row is not proof of non-existence: probe the mirror so a Backlog ticket reads as
186
+ // known. The mirror compares identifiers exactly, so normalize as the row lookup above does —
187
+ // otherwise `explain eng-7` probes a lowercase id, 404s, and reports an existing ENG-7 unknown.
188
+ // Deliberately OUTSIDE the `try`: the probe is the ONLY read that can prove this id exists, so it
189
+ // must still run when the eligibility read was unavailable, and its own failure must surface as
190
+ // itself — a refusal naming the /issues/ read — never as a gate paragraph or as an eligibility
191
+ // failure. That is the pre-4defc34 behaviour for this call and it is restored unchanged.
148
192
  if (!row) {
149
193
  const probe = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket.toUpperCase())}`, { accept: [404] });
150
194
  if (probe.status !== 404)
151
195
  knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
152
196
  }
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);
197
+ // The gate is TEAM-wide: it can say why a real ticket in that team cannot start, never that this
198
+ // id exists. Applied only when the probe found the ticket; a 404 keeps the unknown wording.
199
+ liveGate = !row && knownState && liveGateRaw ? { ...liveGateRaw, source: "live" } : null;
200
+ // The cached gate is team-wide too, so it answers on exactly the same condition as the live one:
201
+ // the mirror found the ticket and no row explains it. An unavailable eligibility read licenses the
202
+ // FALLBACK, never the claim that this id exists.
203
+ const usingCached = !liveGate && !row && knownState !== null;
204
+ const gate = liveGate ?? (usingCached ? cachedGate : null);
205
+ // Both sides spoke and disagreed: the live read wins, and the paragraph says the cache disagreed.
206
+ // Computed together with its sentence so the "live is non-null here" fact never needs re-asserting.
207
+ let overridden = null;
208
+ let overriddenNote = null;
209
+ if (liveGate && cachedGate && liveGate.cause !== cachedGate.cause) {
210
+ overridden = cachedGate;
211
+ overriddenNote = `The cached tenant contract says ${cachedGate.cause}; this live eligibility read says ${liveGate.cause} and wins.`;
212
+ }
213
+ // A live read that produced a row contradicts a cached "nothing in this team can start".
214
+ const contradictedByRow = !liveFailure && row && cachedGate ? cachedGate : null;
215
+ const parts = [renderExplain(ticket, row, team, knownState, gate)];
216
+ if (gate && gate === cachedGate) {
217
+ parts.push(liveFailure
218
+ ? `Read from the cached tenant contract: the live eligibility read was unavailable (${liveFailure.message}).`
219
+ : "Read from the cached tenant contract: this cloud's eligibility read sent no dispatch gate.");
220
+ }
221
+ if (overriddenNote)
222
+ parts.push(overriddenNote);
223
+ // The mirror answered and the eligibility read did not: say so, rather than let a network outage
224
+ // read as a confident verdict from an explainer this run never actually reached.
225
+ if (liveFailure && gate !== cachedGate) {
226
+ parts.push(`The live eligibility read was unavailable (${liveFailure.message}); only the mirror answered.`);
227
+ }
228
+ if (contradictedByRow) {
229
+ parts.push(`The cached tenant contract says ${contradictedByRow.cause} for team ${team}; this live eligibility read has a row for ${ticket} and wins.`);
230
+ }
231
+ const explanation = parts.join(" ");
157
232
  if (args.json) {
158
- ctx.stdout(JSON.stringify({ ticket, row, ...(gate ? { dispatchGate: gate } : {}), explanation }));
233
+ ctx.stdout(JSON.stringify({
234
+ ticket,
235
+ row,
236
+ ...(gate ? { dispatchGate: gate } : {}),
237
+ ...(overridden || contradictedByRow ? { dispatchGateOverridden: overridden ?? contradictedByRow } : {}),
238
+ ...(liveFailure ? { liveReadFailed: liveFailure.message } : {}),
239
+ explanation,
240
+ }));
159
241
  }
160
242
  else {
161
243
  ctx.stdout(explanation);