@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.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 (111) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +109 -53
  3. package/dist/args.js +237 -0
  4. package/dist/ask.js +137 -0
  5. package/dist/cli.js +165 -279
  6. package/dist/config.js +106 -0
  7. package/dist/contract-types.js +4 -0
  8. package/dist/contract.js +178 -0
  9. package/dist/detach.js +10 -0
  10. package/dist/errors.js +27 -0
  11. package/dist/execution.js +156 -0
  12. package/dist/http.js +142 -0
  13. package/dist/prompt.js +35 -0
  14. package/dist/query.js +187 -0
  15. package/dist/ready.js +123 -0
  16. package/dist/replica.js +318 -0
  17. package/dist/sdk.js +26 -0
  18. package/dist/skill-shape.js +179 -0
  19. package/dist/skills.js +74 -0
  20. package/dist/ts-deps-loader.js +68 -0
  21. package/dist/watch/consumer.js +141 -0
  22. package/dist/watch/cursor-file.js +62 -0
  23. package/dist/watch.js +173 -0
  24. package/dist/write.js +224 -0
  25. package/package.json +9 -5
  26. package/skills/catalyst-github/SKILL.md +35 -0
  27. package/skills/catalyst-github/agents/openai.yaml +6 -0
  28. package/skills/catalyst-github/agents/portability.yaml +4 -0
  29. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  30. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  31. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  32. package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
  33. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  34. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  35. package/skills/catalyst-linear/SKILL.md +43 -0
  36. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  37. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  38. package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
  39. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  40. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  41. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  42. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  43. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  44. package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
  45. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  46. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  47. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  48. package/skills/catalyst-setup/SKILL.md +32 -0
  49. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  50. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  51. package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
  52. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  53. package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
  54. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  55. package/skills/connect-me/SKILL.md +55 -0
  56. package/skills/connect-me/agents/openai.yaml +6 -0
  57. package/skills/connect-me/agents/portability.yaml +5 -0
  58. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  59. package/skills/connect-me/scripts/lib/cli.mjs +184 -0
  60. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  61. package/skills/how-catalyst-works/SKILL.md +42 -0
  62. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  63. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  64. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  65. package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
  66. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  67. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  68. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  69. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  70. package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
  71. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  72. package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
  73. package/skills/run-this-project/SKILL.md +45 -0
  74. package/skills/run-this-project/agents/openai.yaml +6 -0
  75. package/skills/run-this-project/agents/portability.yaml +5 -0
  76. package/skills/run-this-project/assets/stall-policy.json +15 -0
  77. package/skills/run-this-project/references/making-work-ready.md +60 -0
  78. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  79. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  80. package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
  81. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  82. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  83. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  84. package/skills/what-needs-me/SKILL.md +41 -0
  85. package/skills/what-needs-me/agents/openai.yaml +6 -0
  86. package/skills/what-needs-me/agents/portability.yaml +5 -0
  87. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  88. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  89. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  90. package/skills/what-needs-me/scripts/inbox.mjs +47 -0
  91. package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
  92. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  93. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  94. package/skills/whats-happening/SKILL.md +43 -0
  95. package/skills/whats-happening/agents/openai.yaml +6 -0
  96. package/skills/whats-happening/agents/portability.yaml +4 -0
  97. package/skills/whats-happening/assets/status-reply.json +77 -0
  98. package/skills/whats-happening/references/reading-the-board.md +43 -0
  99. package/skills/whats-happening/references/reprioritising.md +37 -0
  100. package/skills/whats-happening/references/routing-work.md +36 -0
  101. package/skills/whats-happening/references/status-reply.md +34 -0
  102. package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
  103. package/skills/whats-happening/scripts/explain.mjs +28 -0
  104. package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
  105. package/skills/whats-happening/scripts/snapshot.mjs +145 -0
  106. package/skills/ask/SKILL.md +0 -37
  107. package/skills/concierge/SKILL.md +0 -41
  108. package/skills/join/SKILL.md +0 -30
  109. package/skills/linearis/SKILL.md +0 -40
  110. package/skills/setup/SKILL.md +0 -27
  111. package/skills/steward/SKILL.md +0 -30
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: how-catalyst-works
3
+ description:
4
+ How Catalyst Cloud runs a ticket on the customer's own tenant, as facts an agent loads on demand: the eight-phase ladder and what each phase produces, the eleven board slots and this team's live stage map, what happens when a phase fails (retry, backoff, Remediate, park), how the queue is ordered and routed and every reason a ticket is excluded, and the coding-account model. Use when a person asks "how does this work?", "why did it do that?", "why is this stuck?", "what runs next?" or "how does it prioritise?". Read-only; its scripts explain one ticket's eligibility in plain English, show what is running and queued, and print the tenant's stage map and thresholds straight from the contract.
5
+ allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
+ ---
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills — written in this repository for customer tenants -->
8
+
9
+ # How Catalyst works
10
+
11
+ You explain the machine. A person asks why Catalyst did something, what it will do next, or how it decides; you answer from the tenant's live contract and eligibility explainer, and from the invariants in the references below. You never guess a stage name, a label, a threshold or a route: the scripts print the live values.
12
+
13
+ Every tenant-specific fact (stage names and ids, label ids, the ladder keying, the thresholds, the merge policy) comes from `GET /api/v1/agent/contract`, cached per session by the `catalyst-skills` CLI. The references restate only what does not vary per tenant.
14
+
15
+ ## Run first
16
+
17
+ Run each with `--help` before reading anything else; the scripts are black boxes, not reading material.
18
+
19
+ - `node scripts/explain-ticket.mjs <ticket>` — why one ticket is or is not about to run, as one paragraph plus the raw eligibility row.
20
+ - `node scripts/whats-running.mjs [--queue] [--accounts] [--team <key>]` — fleet activity, the agent roster and lease attributions; with `--queue` the dispatch order; with `--accounts` the coding-account line.
21
+ - `node scripts/show-my-map.mjs [--team <key>]` — this tenant's slot-to-stage map, ladder and live thresholds.
22
+
23
+ Exit codes: 0 answered, 1 not found or a usage error, 2 this machine is not connected or the cloud refused (the one line printed says which; connect first if it names the login).
24
+
25
+ ## Load on demand
26
+
27
+ | when | read |
28
+ | -- | -- |
29
+ | "what are the phases, what does each produce, when is a ticket Done?" | `references/the-ladder.md` |
30
+ | "which column is which, why does nothing dispatch, what is a slot?" | `references/stages-and-mapping.md` |
31
+ | a phase FAILED, a card went to Remediate, a ticket is parked or on hold | `references/when-a-phase-fails.md` |
32
+ | "what runs next, why not this one, what does this exclusion reason mean?" | `references/what-runs-next.md` |
33
+ | "why is nothing running", walls, quarantine, which provider ran a phase | `references/coding-accounts.md` |
34
+
35
+ ## Rules
36
+
37
+ - **Print, never recall.** A stage name, label, threshold or route in your answer must have come from a script's output in this session.
38
+ - **A reason is a layer.** Translate an exclusion reason through `references/what-runs-next.md`; name what releases it and who can do that (a clock, a comment, a push, an operator).
39
+ - **Unknown is not absent.** An `unknown` verdict from the explainer means the cloud could not look; report it as inconclusive.
40
+ - **System causes are one alert.** A provider outage, the runner image breaker or a paused repository holds many tickets for one reason; never escalate it ticket by ticket.
41
+ - **Say what a key cannot see.** Coding-account status and per-ticket execution history are not readable with an account key yet; the scripts say so and name the settings page. Do not fill the gap with a guess.
42
+ - **Depth lives elsewhere.** What a ticket accumulates in Linear is `catalyst-linear`; what a PR accumulates and whether it is mergeable is `catalyst-github`; raising a decision is `what-needs-me`.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "How Catalyst Works"
3
+ short_description: "Explain how Catalyst Cloud runs a ticket: the ladder, your stage map, failures, what runs next, coding accounts"
4
+ default_prompt: "Use $how-catalyst-works to explain why Catalyst did that, or what it will run next."
5
+ policy:
6
+ allow_implicit_invocation: true
@@ -0,0 +1,4 @@
1
+ identity: { pack: catalyst-cloud-skills, skill: how-catalyst-works }
2
+ effects: []
3
+ invocation: implicit
4
+ exposure: [catalog]
@@ -0,0 +1,51 @@
1
+ # Coding accounts: slots, windows, walls, and what a key cannot read yet
2
+
3
+ This reference restates the model, which does not vary per tenant. Your tenant's actual accounts, their usage and their state are NOT readable with an account key today: `node scripts/whats-running.mjs --accounts` prints that by name, with the settings page where the facts live. When a tenant-facing read ships, the same script will print the live rows and this reference stays true.
4
+
5
+ ## What a slot is
6
+
7
+ A slot is one enrolled coding-account credential: a subscription or token that runs phases. A tenant may enrol several accounts of the same provider under the same email, because identity is the credential, not the email; the email is a display field. A revoked slot leaves the roster.
8
+
9
+ ## Providers and harnesses
10
+
11
+ | Provider | Harness | Credential |
12
+ | -- | -- | -- |
13
+ | Claude | the Claude CLI | its own subscription |
14
+ | Codex | Codex | its own subscription |
15
+ | GLM | the Claude CLI | its own token |
16
+ | Qwen | the Claude CLI | its own token |
17
+ | GLM via OpenCode | OpenCode | the same GLM enrolment |
18
+ | Qwen via OpenCode | OpenCode | the same Qwen enrolment |
19
+
20
+ The harness is a property of the catalog entry, never a routing input. Which provider a phase runs on is the routing decision in `references/what-runs-next.md`; the routing rows, not the slot, name the model.
21
+
22
+ ## Two state axes
23
+
24
+ - **Declared**: `active` or `disabled`. The operator sets it.
25
+ - **Observed**: `healthy`, `degraded` or `unknown`. The poller sets it from what the provider reports.
26
+
27
+ Two more lifecycle facts sit beside them: **quarantined** (system-set, on a credential conflict or an authentication mismatch; sticky, only an operator clears it) and **revoked** (operator-set).
28
+
29
+ ## Windows, walls, headroom
30
+
31
+ - Subscriptions meter usage over a **5-hour** window and a **7-day** window, each with a used percentage and a reset time.
32
+ - A **wall** is the window limit a session can die at mid-run. Before a slot is granted, a fit gate projects the phase's expected burn against the remaining headroom with a safety margin; a slot that would hit the wall is not offered.
33
+ - **Headroom** per provider is advisory: it counts eligible and degraded slots and the best remaining percentage, and it can never promise what a reservation would refuse because both read the same eligibility predicate.
34
+ - Holds: a Claude slot can serve several phases at once up to a per-account cap; a Codex slot serves one at a time because its refresh tokens are single-use.
35
+ - Slot choice orders by usage band, then live-hold count, then used percent, then the latest reset, so bursts spread across accounts rather than stacking on one.
36
+
37
+ A poller refreshes an active slot's usage every 5 minutes and a disabled one daily. Separately, vendor status pages are polled for provider health; that signal says nothing about your own accounts' windows, walls or quarantine.
38
+
39
+ ## What the settings page shows
40
+
41
+ Your tenant's coding-accounts page lists each slot with a five-value status, first match wins: expired-or-revoked, walled, active, attested (healthy and active, but the provider is unobserved), unobserved. A drilldown shows the windows, the holds and the history.
42
+
43
+ ## What a key cannot read yet, and what to say
44
+
45
+ Coding-account status (provider, declared and observed state, window percentages and resets, walls, quarantine, live holds) is served only behind the tenant's admin gate. So when a human asks "why is nothing running?":
46
+
47
+ 1. Run `node scripts/explain-ticket.mjs <ticket>` for a stuck ticket. `routing_unavailable` with a detail naming a slot or provider, or `no_eligible_account_slot` in the routing block, points at accounts.
48
+ 2. Say plainly that the account key cannot read account state, and give the settings link the script prints. Do not guess a wall or a quarantine from silence.
49
+ 3. Treat it as ONE fleet-level cause for every ticket it holds, never as a per-ticket escalation.
50
+
51
+ The customer session never holds or mints a coding-account credential; phases run in the cloud on the enrolled accounts, and repository access is a per-phase installation token.
@@ -0,0 +1,54 @@
1
+ # Stages and mapping: eleven slots, five that matter, and how to read this team's map
2
+
3
+ This reference restates invariants (the slot vocabulary, which slots are load-bearing, the allowed state types, the three mapping modes). Everything about YOUR team — which Linear stage each slot maps to, its name, its type, whether it still exists — is live on the contract under `teams[].stages`. Read it with `node scripts/show-my-map.mjs [--team <key>]`; never quote a stage name or id from memory.
4
+
5
+ ## The eleven slots, in pipeline order
6
+
7
+ `dispatch`, `intake`, `research`, `plan`, `implement`, `remediate`, `verify`, `review`, `pr`, `done`, `canceled`
8
+
9
+ A slot is Catalyst's name for a position on the board. A stage is the Linear workflow state your team actually has. Mapping is the per-team table that binds each slot to one stage.
10
+
11
+ ## Five slots are load-bearing
12
+
13
+ | Slot | Why it matters | Allowed Linear state types |
14
+ | -- | -- | -- |
15
+ | `dispatch` | Moving a card here is how work is dispatched. Nothing is offered from any other column. | `unstarted`, `backlog` |
16
+ | `intake` | Where a never-seen ticket lands for the optional intake pass. | `unstarted`, `backlog` (never Linear's reserved triage type) |
17
+ | `pr` | Where a card must sit for `merge` to be offered. | `started` |
18
+ | `done` | Written by the merge webhook. | `completed` |
19
+ | `canceled` | Terminal; a card here is never work. | `canceled` |
20
+
21
+ An absent or wrongly-typed mapping on one of these five is a distinct silent failure: the ladder simply never moves. The other six slots are informational — a wrong value costs a warning in readiness, not a stall. That asymmetry is why "map my stages" is a five-field decision.
22
+
23
+ ## Three mapping modes
24
+
25
+ The contract's `teams[].workflowMode` reports which one a team is in:
26
+
27
+ - **adopted**: Catalyst created its recommended stage set for the team, one stage per slot, with verify and review sharing one validation stage.
28
+ - **mapped**: the team kept its existing stages and a human chose which stage fills each slot.
29
+ - **mixed**: some slots adopted, some hand-chosen.
30
+
31
+ `teams[].gitAutomation` is a separate switch, off by default, because enabling it can delete a team's own review automation in Linear; it is never bundled into "adopt recommended".
32
+
33
+ ## The state id is the authority; names are display
34
+
35
+ Each mapped stage carries a `stateId`, a display `name`, a `type`, `stateStillExists` and a `source` (how the mapping was chosen). Only the id is a lookup key. A Linear-to-Linear import can preserve every human-readable name while re-minting every state id, and then a name-based lookup points at nothing. So:
36
+
37
+ - Move cards by slot (`catalyst-linear`'s move script does this) and let the CLI resolve the id from the contract. Never move a card "to Todo" by name.
38
+ - `stateStillExists: false` means the mapped state is provably gone; the map needs fixing in your tenant settings before that slot can be written to. The CLI refuses such a move rather than guessing.
39
+ - The stage names the contract shows come from the mirror's live view of Linear, not from a stored snapshot, so they are current at read time.
40
+
41
+ ## How to read the printed map
42
+
43
+ `node scripts/show-my-map.mjs` prints, per team, one row per slot: the slot, the stage name (or `(unmapped)`, or `(state gone)`), the state type, whether the state still exists, and the source. Load-bearing slots are starred. Then the team's ask, hold and release labels with `(absent)` where the workspace has no such label, then the ladder (phases, keying, intake on or off) and the live thresholds.
44
+
45
+ Reading it for a question:
46
+
47
+ - "Why does nothing dispatch?" — is `dispatch` mapped, does its state still exist, and is the card actually in that stage? `node scripts/explain-ticket.mjs <ticket>` names `not_at_dispatch_stage` when the card is elsewhere.
48
+ - "Why is merge not running?" — is the card in the `pr` slot's stage?
49
+ - "Why did the card go to Remediate?" — `remediate` is mapped, so a failed phase moved it there (see `references/when-a-phase-fails.md`); if `remediate` is unmapped the same episode shows up as the hold label from `teams[].labels.hold` instead.
50
+ - "The team's Backlog is not in the list" — correct. Backlog is not a slot. Parking a card is a move to the team's backlog-type state, which the CLI resolves from the team's live workflow states rather than from the map.
51
+
52
+ ## Readiness over the mapping
53
+
54
+ The contract carries a ten-check readiness vector per team (`teams[].readiness`), including whether every mapped state exists, whether the mapping is total, whether the types are compatible, whether the labels are present, whether writes land and whether the webhook covers the team. A check the cloud could not run is `unknown`, never `pass`. `catalyst-setup` reads this vector; this skill only points at it.
@@ -0,0 +1,41 @@
1
+ # The ladder: eight phases, what each one produces
2
+
3
+ This reference restates invariants. The phase order and what each phase leaves behind do not vary per tenant. The only live values are whether intake is switched on for your tenant and which keying the advance table uses; both are on the contract under `ladder` (print them with `node scripts/show-my-map.mjs`).
4
+
5
+ ## The phases, in order
6
+
7
+ | Phase | What it produces | Notes |
8
+ | -- | -- | -- |
9
+ | `intake` | nothing projected (a classification pass) | Optional head. Offered only to a ticket that has never entered the ladder, and only when the contract says `ladder.intakeEnabled` is true. |
10
+ | `research` | `research.md` | The first ladder phase. Offered when the card sits in the team's dispatch slot. |
11
+ | `plan` | `plan.md` | |
12
+ | `implement` | `implement.md` | Creates the ticket branch (named exactly the ticket identifier) and opens a draft pull request. |
13
+ | `validate` | `validation.md` | Its success moves the card to the team's review stage; verify and review are two slots that both land on that stage. |
14
+ | `pr` | `pr.md` | The first line is the pull-request title, then a blank line, then the body. The phase rebases onto the default branch, force-pushes, writes the title and body, and marks the PR ready for review. |
15
+ | `remediate` | `remediation.json` | An interrupt, not a step forward. It repairs a phase that failed and never advances the card past the PR slot. |
16
+ | `merge` | a receipt, no artifact | Applies the queue-ready label once its evidence gate passes. It writes no board state. |
17
+
18
+ Every artifact-bearing phase (all but `intake` and `merge`) projects its artifact into Linear as a document attached to the ticket, with a short link comment; `catalyst-linear` describes those shapes.
19
+
20
+ ## What moves the card
21
+
22
+ An advance table maps a phase outcome to a card move: research done moves the card from the dispatch slot to the research slot, plan done from research to plan, implement done from plan to implement, validate done from implement to review, pr done from review to pr. The table is served on the contract as `ladder.advance` so you can read the exact rows; do not restate them from memory, because the tenant's keying decides whether a stage names the phase that just finished (trailing, the default) or the phase still ahead (leading).
23
+
24
+ Three rows never move the card:
25
+
26
+ - A **failed** phase writes no board state. The ticket stays where it was, retryable, and the failure handling in `references/when-a-phase-fails.md` takes over.
27
+ - **`remediate` done** writes nothing by itself; the card restores to its exact pre-failure stage once a remediation round succeeds.
28
+ - **`merge` done** writes nothing. The receipt only ever meant "the queue-ready label was applied".
29
+
30
+ ## Done is written by the merge, never by a phase
31
+
32
+ The terminal move to the done slot is keyed to the real pull-request-merged event from GitHub, not to any phase receipt. When the PR merges, the mirror's own ingest path moves the ticket to Done within a few seconds, with no session and no human sweep, and a bounded periodic sweep backstops a dropped webhook. So:
33
+
34
+ - A ticket still not Done a minute after its PR merged is a finding, not a chore to hand-close.
35
+ - Done is not the same as live. A change to something that is deployed separately is inert until that deployment happens; the ticket state says the code merged, nothing more.
36
+
37
+ ## How to read a ticket's position on the ladder
38
+
39
+ 1. The card's stage tells you which phase last finished (under trailing keying) — read the team's map with `node scripts/show-my-map.mjs --team <key>` to translate a stage name into a slot.
40
+ 2. The phase-outcome comments on the ticket tell you which attempts ran and how they ended; the document attachments are the artifacts themselves.
41
+ 3. `node scripts/explain-ticket.mjs <ticket>` tells you what the cloud will run next, or why it will not.
@@ -0,0 +1,77 @@
1
+ # What runs next: queue order, concurrency, routing, and every exclusion reason
2
+
3
+ This reference restates invariants. The live queue is `node scripts/whats-running.mjs --queue [--team <key>]`; one ticket's verdict is `node scripts/explain-ticket.mjs <ticket>`. The reason phrases below match the CLI's own table, so a reason you see printed can be looked up here.
4
+
5
+ ## Queue order
6
+
7
+ Each team's dispatch queue is derived by the tenant's own store, continuously, from the board:
8
+
9
+ 1. Queued tickets (cards in the dispatch slot) rank by **priority ascending, then created time ascending, then identifier ascending**.
10
+ 2. Mid-ladder tickets (any ticket with a phase already completed) join the candidate set and order **by completed-phase count descending**, so a ticket close to the end goes before one that just started. That count is an ordering surrogate only; it never decides membership or eligibility.
11
+
12
+ The queue re-derives within seconds of any board change and at the top of every periodic pass as a backstop. `dispatch_queue.source` reads `self-derived` when the tenant store built it itself, which is the normal case.
13
+
14
+ ## Concurrency
15
+
16
+ - Each repository has a concurrency cap (default 20 running phases). An operator can raise or lower it; a paused repository resolves to a cap of zero without touching the stored value, so resuming restores it.
17
+ - Dispatch buckets by repository and splits slots across teams so no team starves another. A slot that is running, might be running, or is restarting is never free capacity.
18
+ - Comment-wake work (an agent answering a human comment) shares the same cap as ladder work.
19
+
20
+ ## The routing decision, in five checks
21
+
22
+ When a phase is about to start, a route is chosen per candidate, in order, and the first survivor wins:
23
+
24
+ 1. capability match (can this route run this phase);
25
+ 2. a model is configured;
26
+ 3. the provider is available;
27
+ 4. if the candidate needs a coding-account slot: an eligible slot exists and the headroom floor is met;
28
+ 5. degraded, equivalent or stage-default parameters resolve.
29
+
30
+ No survivor is `no_eligible_account_slot` when any candidate was skipped on capacity, else `routing_unavailable` (a misconfiguration). See `references/coding-accounts.md` for what a slot is.
31
+
32
+ ## Every exclusion reason, one line each
33
+
34
+ | Reason | Meaning |
35
+ | -- | -- |
36
+ | `ticket_terminal` | the ticket is in a done or canceled state |
37
+ | `pipeline_complete` | every phase has already completed |
38
+ | `not_at_dispatch_stage` | the card is not in the team's dispatch column; move it there to dispatch |
39
+ | `not_at_pr_stage` | merge is next but the card is not in the PR column (waived when a tenant automation bounced it off PR and the bounce was recorded) |
40
+ | `blocked` | a live blocking relation; the blocker closes first |
41
+ | `cooling_down` | the offered phase is parked; an operator or a callback releases it, not a clock |
42
+ | `lease_held` | a live container already holds this phase |
43
+ | `intake_lease_held` | a later phase is offered while an intake container still holds the ticket |
44
+ | `ask_ticket` | it carries an ask label; a question is never work |
45
+ | `ask_shape_suspected` | its own text reads as a decision request; a human releases it with the release label from the contract's `vocabulary` |
46
+ | `externally_claimed` | a worker outside the cloud claimed it |
47
+ | `environment_check_required` | the repository's environment check has not run |
48
+ | `environment_check_running` | the environment check is in flight |
49
+ | `environment_check_failed` | the environment check failed |
50
+ | `environment_check_expired` | the environment verdict aged out |
51
+ | `environment_check_hash_mismatch` | the environment changed since the verdict |
52
+ | `scope_overlap` | its declared file scope intersects a ticket in flight (implement only; enforced once a team activates a scope policy) |
53
+ | `waiting_on` | a merge-gate failure with no remediable cause holds the card at PR |
54
+ | `branch_missing` | the ticket branch has never been seen |
55
+ | `branch_gone` | the ticket branch was deleted |
56
+ | `pr_merged` | its PR merged and no other PR is open |
57
+ | `no_change_hold` | a remediate round changed nothing; a human comment or a new push releases it |
58
+ | `validate_class_spent` | this validate failure already spent its one repair round |
59
+ | `stale_failure_episode` | the ladder advanced after the failure, so the round would repair a phase already passed |
60
+ | `runner_image_breaker` | fleet-wide: the live runner image fails every phase at startup; clears when the pin moves |
61
+ | `no_branch_to_remediate` | a remediate round is queued on a ticket with no branch |
62
+ | `retry_backoff` | retrying in place, waiting out its 2/5/15-minute rung |
63
+ | `routing_unavailable` | claimed then refused at kickoff: no route, no eligible slot, or the provider is unavailable; the detail names which |
64
+ | `repo_paused` | an operator paused the repository |
65
+ | `remediate_parked` | the remediate phase is parked, so the failing phase has nowhere to be repaired |
66
+
67
+ ## The unknowns (the evaluator fails closed)
68
+
69
+ When the cloud cannot answer, it says so rather than guessing: `ordering_never_published`, `ordering_stale`, `workflow_mapping_unknown`, `ticket_unknown`, `dependency_snapshot_unknown`, `blocker_unknown`, `label_snapshot_unknown`, `prior_artifact_unknown`, `scope_unknown`, `scope_occupancy_unknown`, `branch_snapshot_unknown`. An unknown is "I could not look", never "it is not there"; report it as inconclusive.
70
+
71
+ One advisory gates nothing: `human_addressed_unlabeled_ask_suspect` (assigned to a human with no delegate; possibly an unlabelled ask).
72
+
73
+ ## The three levers a human has
74
+
75
+ 1. **Priority** on the ticket reorders the queue.
76
+ 2. **The dispatch column**: moving a card into the dispatch slot dispatches it; moving it to the team's backlog-type state parks it and stops rounds.
77
+ 3. **Holds**: the hold labels on the pull request (from the contract's `merge.prLabels`) keep a green PR out of the merge queue; a human comment on the ticket clears the validate hold and the no-change hold.
@@ -0,0 +1,57 @@
1
+ # When a phase fails: four layers, in order
2
+
3
+ This reference restates the mechanism, which does not vary per tenant. The numbers quoted are the fleet defaults; the live values are served on the contract under `thresholds` and `node scripts/show-my-map.mjs` prints them. Quote the printed values, not these.
4
+
5
+ A failed phase writes no board state on its own: the ticket stays at its current stage, retryable. What happens next is decided by four stacked layers.
6
+
7
+ ## Layer 1: retry in place
8
+
9
+ Some failures are not the ticket's fault, so the SAME phase is simply re-offered and no remediation round is minted:
10
+
11
+ - the phase failed before any branch existed (research, plan or implement on a ticket whose own record shows no branch);
12
+ - an infrastructure failure at any phase: a vendor 5xx, a dropped stream, a refused setup, an environment gap, a phase timeout, an unwritable workspace.
13
+
14
+ Nothing is written for the retry; the ordinary dispatch path re-offers the phase. A remediation round on a branchless ticket could only fail to find the branch while spending a counted attempt, which is why this layer exists.
15
+
16
+ ## Layer 2: backoff
17
+
18
+ Every retry-in-place, and a routing refusal at kickoff, waits out a rung of a backoff ladder before the phase is re-offered: 2 minutes, then 5, then 15, indexed by the consecutive-failure count and clamped at the last rung. While waiting, the eligibility explainer shows `retry_backoff` (or `routing_unavailable` with a kickoff detail). `node scripts/explain-ticket.mjs <ticket>` prints it. The live ladder is `thresholds.retryBackoffMs`.
19
+
20
+ ## Layer 3: move to Remediate
21
+
22
+ Any failure that is not retry-in-place moves the card to the team's remediate stage and queues a remediation round. The round is an interrupt phase (`remediate`) that repairs what failed; when a round succeeds, the card restores to its exact pre-failure stage. A team with no remediate stage mapped gets the hold label from `teams[].labels.hold` instead of a state move.
23
+
24
+ Rounds are capped: after `thresholds.remediateRoundCap` rounds (default 3) in one failure episode, no further round is queued. A remediation round that itself fails never queues another round. A round that changes nothing puts the ticket on a no-change hold, released by a human comment on the ticket or a new push, never by a clock. A validate failure with the same fingerprint spends only one repair round per episode.
25
+
26
+ Two structural cases the round machinery refuses: a `remediate` round is never offered on a ticket with no recorded branch (`no_branch_to_remediate`), and one dispatched anyway is cancelled at the clone.
27
+
28
+ ## Layer 4: park
29
+
30
+ After `thresholds.parkAfterConsecutiveFailures` consecutive failures (default 3) the phase is parked. A parked phase shows as `cooling_down` in the explainer; the ticket's `remediate` phase being parked shows as `remediate_parked`.
31
+
32
+ Which parks release themselves:
33
+
34
+ | Park | Releases by |
35
+ | -- | -- |
36
+ | repeated failure | an operator, not a clock |
37
+ | remediate round cap reached | an operator, not a clock |
38
+ | missing branch | its own budgeted release loop |
39
+ | rebase conflict | its own budgeted release loop |
40
+
41
+ An account key cannot release a park today; the repair verbs are operator-only. When a ticket is parked, say so, name the failure class the explainer shows, and hand the release to whoever administers the tenant.
42
+
43
+ ## Two holds that are not failures
44
+
45
+ - **Validate budget hold**: a validate failure that has spent its repair round holds the card; a human comment on the ticket clears it.
46
+ - **Runner image breaker**: fleet-wide, not per ticket. When three distinct tickets fail the same startup class on the live runner image, dispatch pauses for the affected tickets, one anomaly alert is raised per tenant, and dispatch resumes automatically when the image pin moves. The explainer shows `runner_image_breaker`. Never escalate this one ticket at a time.
47
+
48
+ ## What a human sees on the ticket
49
+
50
+ Each attempt posts a phase-outcome comment (complete or FAILED, with phase, attempt, artifact, a summary and any park or hold block) and each remediation round posts a remediate-attempt comment naming the failure class it is repairing. `catalyst-linear` describes the shapes. The round count of the cap and the park history are not readable with an account key yet; `catalyst-skills explain --history <ticket>` says so and names where to read them.
51
+
52
+ ## Rule of thumb for answering "why is it stuck?"
53
+
54
+ 1. Run `node scripts/explain-ticket.mjs <ticket>`; the reason names the layer.
55
+ 2. `retry_backoff` or `routing_unavailable`: wait; it retries itself. Say when.
56
+ 3. `cooling_down`, `remediate_parked`, `no_change_hold`, `validate_class_spent`: name what releases it (an operator, a comment, a push) and who can do that.
57
+ 4. A system-level cause (provider down, runner image breaker, repo paused) is ONE alert, never a per-ticket escalation.
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // explain-ticket.mjs — why one ticket is, or is not, about to run: the cloud's own eligibility row
3
+ // for it, rendered as one paragraph, plus the raw row so the failure block is not lost.
4
+ import { exitOnFailure, parseFlags, parseJson, relayStderr, runCli, usage, wantsHelp } from "./lib/cli.mjs";
5
+
6
+ const HELP = `Usage: node scripts/explain-ticket.mjs <ticket> [--json]
7
+
8
+ Asks Catalyst Cloud for the ticket's eligibility row (position, status, the exclusion reason in plain
9
+ English, the last failure, advisories) and prints it as one paragraph. Wraps: catalyst-skills explain.
10
+
11
+ <ticket> the Linear identifier, e.g. KEY-123
12
+ --json print the CLI's JSON document instead of the paragraph
13
+
14
+ Exit 0 explained (even when the ticket is excluded), 1 when the ticket is unknown to the mirror or
15
+ a usage error, 2 when this machine is not connected to a tenant or the cloud refused the read
16
+ (the line says which).`;
17
+
18
+ const argv = process.argv.slice(2);
19
+ if (wantsHelp(argv)) {
20
+ console.log(HELP);
21
+ process.exit(0);
22
+ }
23
+ const { flags, positionals } = parseFlags(argv, { bool: ["json"] });
24
+ const ticket = positionals[0];
25
+ if (!ticket) usage("explain-ticket needs a ticket identifier (see --help)");
26
+
27
+ const r = runCli(["explain", ticket, "--json"]);
28
+ exitOnFailure(r);
29
+ relayStderr(r);
30
+ const doc = parseJson(r.stdout);
31
+ if (!doc || typeof doc.explanation !== "string") {
32
+ console.log(r.stdout.trimEnd());
33
+ process.exit(1);
34
+ }
35
+ if (flags.json) {
36
+ console.log(JSON.stringify(doc));
37
+ } else {
38
+ console.log(doc.explanation);
39
+ if (doc.row) console.log(`row: ${JSON.stringify(doc.row)}`);
40
+ }
41
+ process.exit(doc.row ? 0 : 1);
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // lib/cli.mjs — the one way a skill script reaches Catalyst Cloud: it spawns the catalyst-skills CLI
3
+ // this machine connected with (the path recorded in customer.json, else npx) and hands back its
4
+ // output. Scripts import it; a person runs it with --help to see what it does. No dependencies.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ export const PACKAGE = "@catalyst-cloud/catalyst-skills";
11
+ export const CONNECT_HINT = `CATALYST_CLOUD_TOKEN=<account key> npx ${PACKAGE} login`;
12
+
13
+ /** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME (used by tests) over HOME. */
14
+ export function configPath() {
15
+ const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
16
+ return join(home, ".config", "catalyst-cloud", "customer.json");
17
+ }
18
+
19
+ /** The config, or a one-line reason it could not be read. Never throws. */
20
+ export function loadConfig() {
21
+ const path = configPath();
22
+ if (!existsSync(path)) return { ok: false, reason: `no config at ${path}` };
23
+ try {
24
+ const cfg = JSON.parse(readFileSync(path, "utf8"));
25
+ if (!cfg || typeof cfg.key !== "string" || typeof cfg.account !== "string") {
26
+ return { ok: false, reason: `${path} is missing the key or account` };
27
+ }
28
+ return { ok: true, cfg, path };
29
+ } catch (err) {
30
+ return { ok: false, reason: `${path} is unreadable: ${err instanceof Error ? err.message : String(err)}` };
31
+ }
32
+ }
33
+
34
+ /** Exit 2 with the one line every script prints when this machine is not connected to a tenant. */
35
+ export function notConfigured(reason) {
36
+ console.error(`not connected to a Catalyst Cloud tenant (${reason}) — run: ${CONNECT_HINT}`);
37
+ process.exit(2);
38
+ }
39
+
40
+ /**
41
+ * Run one catalyst-skills verb. Returns { code, stdout, stderr }. The CLI is `node <cliPath>` when
42
+ * the config recorded one that still exists, else `npx @catalyst-cloud/catalyst-skills`.
43
+ * Exits 2 (not configured) before spawning anything when the config is absent or unreadable.
44
+ */
45
+ export function runCli(args, { stdin } = {}) {
46
+ const loaded = loadConfig();
47
+ if (!loaded.ok) notConfigured(loaded.reason);
48
+ const { cfg } = loaded;
49
+ let cmd;
50
+ let argv;
51
+ let shell = false;
52
+ if (typeof cfg.cliPath === "string" && existsSync(cfg.cliPath)) {
53
+ cmd = process.execPath;
54
+ argv = [cfg.cliPath, ...args];
55
+ } else {
56
+ cmd = process.platform === "win32" ? "npx.cmd" : "npx";
57
+ argv = [PACKAGE, ...args];
58
+ shell = process.platform === "win32";
59
+ }
60
+ const r = spawnSync(cmd, argv, {
61
+ encoding: "utf8",
62
+ input: stdin,
63
+ env: process.env,
64
+ shell,
65
+ maxBuffer: 64 * 1024 * 1024,
66
+ });
67
+ if (r.error) {
68
+ console.error(`could not run ${cmd}: ${r.error.message} — re-run ${CONNECT_HINT} to record the CLI path`);
69
+ process.exit(2);
70
+ }
71
+ return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
72
+ }
73
+
74
+ /** Print the CLI's stderr (the source line, the contract line, any refusal) on our stderr. */
75
+ export function relayStderr(r) {
76
+ const text = r.stderr.trimEnd();
77
+ if (text) console.error(text);
78
+ }
79
+
80
+ /** A non-zero CLI result ends the script with the same code, its stdout shown so nothing is lost. */
81
+ export function exitOnFailure(r) {
82
+ if (r.code === 0) return;
83
+ relayStderr(r);
84
+ const out = r.stdout.trimEnd();
85
+ if (out) console.log(out);
86
+ process.exit(r.code);
87
+ }
88
+
89
+ export function parseJson(text) {
90
+ try {
91
+ return JSON.parse(text.trim());
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * A small flag parser: `spec.value` names flags that take a value, `spec.bool` boolean flags,
99
+ * `spec.repeat` value flags that may repeat (collected into arrays). Unknown flags are a usage error.
100
+ */
101
+ export function parseFlags(argv, spec = {}) {
102
+ const value = new Set(spec.value ?? []);
103
+ const bool = new Set(spec.bool ?? []);
104
+ const repeat = new Set(spec.repeat ?? []);
105
+ const flags = {};
106
+ const positionals = [];
107
+ for (let i = 0; i < argv.length; i++) {
108
+ const a = argv[i];
109
+ if (a === "--") {
110
+ positionals.push(...argv.slice(i + 1));
111
+ break;
112
+ }
113
+ if (!a.startsWith("--")) {
114
+ positionals.push(a);
115
+ continue;
116
+ }
117
+ let name = a.slice(2);
118
+ let inline;
119
+ const eq = name.indexOf("=");
120
+ if (eq !== -1) {
121
+ inline = name.slice(eq + 1);
122
+ name = name.slice(0, eq);
123
+ }
124
+ if (bool.has(name)) {
125
+ flags[name] = true;
126
+ } else if (value.has(name) || repeat.has(name)) {
127
+ const v = inline ?? argv[++i];
128
+ if (v === undefined || v === "") usage(`--${name} needs a value`);
129
+ if (repeat.has(name)) (flags[name] ??= []).push(v);
130
+ else flags[name] = v;
131
+ } else {
132
+ usage(`unknown option --${name}`);
133
+ }
134
+ }
135
+ return { flags, positionals };
136
+ }
137
+
138
+ export function usage(message) {
139
+ console.error(message);
140
+ process.exit(1);
141
+ }
142
+
143
+ export function wantsHelp(argv) {
144
+ return argv.includes("--help") || argv.includes("-h");
145
+ }
146
+
147
+ const runDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
148
+ if (runDirectly) {
149
+ if (wantsHelp(process.argv.slice(2)) || process.argv.length <= 2) {
150
+ console.log(
151
+ [
152
+ "lib/cli.mjs — shared helper for this skill's scripts (not a command of its own)",
153
+ "",
154
+ "Reads ~/.config/catalyst-cloud/customer.json and runs the catalyst-skills CLI recorded there",
155
+ `(or npx ${PACKAGE} when no path is recorded). Exit 2 when the machine is not connected.`,
156
+ "",
157
+ `Connect first with: ${CONNECT_HINT}`,
158
+ `Config in use: ${configPath()}`,
159
+ ].join("\n"),
160
+ );
161
+ process.exit(0);
162
+ }
163
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // show-my-map.mjs — this tenant's stage map, ladder and thresholds, straight from the contract:
3
+ // per team, every slot with the Linear stage it maps to, its type, whether that state still exists
4
+ // and how the mapping was chosen; then the ladder and the live failure thresholds.
5
+ import { exitOnFailure, parseFlags, parseJson, relayStderr, runCli, wantsHelp } from "./lib/cli.mjs";
6
+
7
+ const HELP = `Usage: node scripts/show-my-map.mjs [--team <key>] [--json]
8
+
9
+ Prints the tenant contract's stage map, ladder and thresholds. Nothing here is guessed: every value is
10
+ read from the contract Catalyst Cloud serves for your tenant. Wraps: catalyst-skills contract --path.
11
+
12
+ --team <key> only this team
13
+ --json one JSON document: { slots, teams, ladder, thresholds }
14
+ --help this text
15
+
16
+ A slot marked * is load-bearing: dispatch, intake, pr, done and canceled must be mapped for the
17
+ ladder to move at all; the others are informational. "(unmapped)" means the team has no stage for
18
+ that slot; "(state gone)" means the mapped Linear state no longer exists and needs re-mapping in
19
+ settings.
20
+
21
+ Exit 0, 1 on a usage error or an unknown team, 2 when this machine is not connected to a tenant or
22
+ the contract could not be read (the line says which).`;
23
+
24
+ const LOAD_BEARING = new Set(["dispatch", "intake", "pr", "done", "canceled"]);
25
+
26
+ const argv = process.argv.slice(2);
27
+ if (wantsHelp(argv)) {
28
+ console.log(HELP);
29
+ process.exit(0);
30
+ }
31
+ const { flags } = parseFlags(argv, { bool: ["json"], value: ["team"] });
32
+
33
+ function contractPath(path) {
34
+ const r = runCli(["contract", "--path", path, "--json"]);
35
+ exitOnFailure(r);
36
+ const v = parseJson(r.stdout);
37
+ if (v === null || v === undefined) {
38
+ relayStderr(r);
39
+ console.error(`contract --path ${path} returned nothing readable`);
40
+ process.exit(1);
41
+ }
42
+ return { value: v, stderr: r.stderr };
43
+ }
44
+
45
+ const slotsRead = contractPath("slots");
46
+ if (slotsRead.stderr.trim()) console.error(slotsRead.stderr.trimEnd());
47
+ const slots = slotsRead.value;
48
+ let teams = contractPath("teams").value;
49
+ const ladder = contractPath("ladder").value;
50
+ const thresholds = contractPath("thresholds").value;
51
+
52
+ if (flags.team) {
53
+ const want = flags.team.toUpperCase();
54
+ teams = teams.filter((t) => String(t.key ?? "").toUpperCase() === want);
55
+ if (teams.length === 0) {
56
+ console.error(`no team with key ${flags.team} on this tenant's contract`);
57
+ process.exit(1);
58
+ }
59
+ }
60
+
61
+ if (flags.json) {
62
+ console.log(JSON.stringify({ slots, teams, ladder, thresholds }));
63
+ process.exit(0);
64
+ }
65
+
66
+ const pad = (s, n) => String(s).padEnd(n);
67
+ for (const team of teams) {
68
+ console.log(`== team ${team.key ?? "(no key)"} — ${team.name ?? team.id} — workflow ${team.workflowMode}, git automation ${team.gitAutomation}, readiness ${team.readiness?.status ?? "unknown"}`);
69
+ console.log(` ${pad("slot", 12)}${pad("stage", 22)}${pad("type", 12)}${pad("exists", 8)}source`);
70
+ for (const slot of slots) {
71
+ const s = team.stages?.[slot];
72
+ const mark = LOAD_BEARING.has(slot) ? "*" : " ";
73
+ if (!s) {
74
+ console.log(` ${mark}${pad(slot, 12)}(unmapped)`);
75
+ continue;
76
+ }
77
+ const name = s.stateStillExists ? (s.name ?? "(unnamed)") : "(state gone)";
78
+ console.log(` ${mark}${pad(slot, 12)}${pad(name, 22)}${pad(s.type ?? "-", 12)}${pad(s.stateStillExists ? "yes" : "NO", 8)}${s.source}`);
79
+ }
80
+ const labels = team.labels ?? {};
81
+ const describe = (list) => (list ?? []).map((l) => `${l.name}${l.preferredId ? "" : " (absent)"}`).join(", ") || "none";
82
+ console.log(` labels: ask ${describe(labels.ask)}; hold ${describe(labels.hold)}; release ${describe(labels.release)}`);
83
+ }
84
+
85
+ console.log("== ladder");
86
+ console.log(` phases: ${(ladder.phases ?? []).join(" → ")}`);
87
+ console.log(` keying: ${ladder.keying} (${ladder.keyingScope}); intake ${ladder.intakeEnabled ? "enabled" : "off"}`);
88
+
89
+ console.log("== thresholds (live, fleet-wide)");
90
+ const minutes = (ms) => `${Math.round(ms / 60000)} min`;
91
+ console.log(` park after ${thresholds.parkAfterConsecutiveFailures} consecutive failures`);
92
+ console.log(` remediate round cap ${thresholds.remediateRoundCap} (escalated budget ${thresholds.remediateEscalatedRoundBudget}, rewind budget ${thresholds.remediateRewindBudget})`);
93
+ console.log(` retry backoff ${(thresholds.retryBackoffMs ?? []).map(minutes).join(", ")}`);
94
+ console.log(` write budget ${thresholds.hostDailyWriteBudget} per host per day`);