rightmodeler 0.2.0 → 0.3.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.
@@ -0,0 +1,328 @@
1
+ # GitHub Actions
2
+
3
+ This workflow runs rightmodeler in GitHub Actions with the job's built-in `GITHUB_TOKEN`. It needs no GitHub App and no personal access token.
4
+
5
+ ## What it does
6
+
7
+ - Runs `init` every Monday and whenever a push to `main` changes `traces/`.
8
+ - Opens the draft pull request only when a person runs the workflow with `command=apply`.
9
+ - Runs `watch` every 6 hours on each swap pull request that is still being watched.
10
+ - Never merges. A person reviews and merges the draft.
11
+
12
+ ## Setup
13
+
14
+ 1. Let GitHub Actions open pull requests. In the repository settings, under Actions, General, Workflow permissions, turn on "Allow GitHub Actions to create and approve pull requests", or run `gh api -X PUT repos/<owner>/<repo>/actions/permissions/workflow -F can_approve_pull_request_reviews=true`. This is the only repository setting the workflow needs. In a repository owned by an organization, the organization must allow it first.
15
+ 2. Set the repository variable `RIGHTMODELER_PROVIDER_BASE_URL` and the repository secret `RIGHTMODELER_PROVIDER_API_KEY`, for example with `gh variable set` and `gh secret set`.
16
+ 3. Commit traces under `traces/`, or change `RIGHTMODELER_TRACES` and the `paths` filter together.
17
+ 4. Change `main` if the default branch has another name, and adjust `RIGHTMODELER_MAX_COST_USD`.
18
+ 5. Save the workflow below as `.github/workflows/rightmodeler.yml`.
19
+
20
+ Each job grants `GITHUB_TOKEN` only what its command needs: `apply` gets `contents: write` and `pull-requests: write`, and `watch` gets `contents: read`, `pull-requests: write`, `checks: read` and `statuses: read`. See [GitHub](github.md) for what each command does with them.
21
+
22
+ ## The workflow
23
+
24
+ ```yaml
25
+ name: rightmodeler
26
+
27
+ on:
28
+ schedule:
29
+ - cron: "17 5 * * 1"
30
+ - cron: "43 */6 * * *"
31
+ push:
32
+ branches: [main]
33
+ paths:
34
+ - "traces/**"
35
+ workflow_dispatch:
36
+ inputs:
37
+ command:
38
+ description: Which rightmodeler command to run
39
+ type: choice
40
+ options: [init, apply, watch]
41
+ default: init
42
+
43
+ permissions:
44
+ contents: read
45
+
46
+ concurrency:
47
+ group: rightmodeler-store
48
+ cancel-in-progress: false
49
+
50
+ env:
51
+ RIGHTMODELER_VERSION: "0.3.0"
52
+ RIGHTMODELER_TRACES: traces
53
+ RIGHTMODELER_MAX_COST_USD: "5"
54
+ RM_ANNOTATE: |
55
+ const { readFileSync } = require("node:fs");
56
+ const data = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
57
+ const prop = (s) => data(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
58
+ for (const file of process.argv.slice(1)) {
59
+ let text = "";
60
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
61
+ for (const line of text.split("\n")) {
62
+ let value;
63
+ try { value = JSON.parse(line); } catch { continue; }
64
+ if (value === null || typeof value !== "object") continue;
65
+ if (value.event === "result") value = value.result;
66
+ if (value.event === "warning") {
67
+ console.log(`::warning title=${prop(`rightmodeler ${value.code}`)}::${data(value.message)}`);
68
+ } else if (value.status === "refused" && Array.isArray(value.reasons)) {
69
+ for (const reason of value.reasons) {
70
+ console.log(`::error title=${prop(`rightmodeler ${reason.code}`)}::${data(reason.message)}`);
71
+ }
72
+ } else if (typeof value.code === "string" && typeof value.message === "string") {
73
+ console.log(`::error title=${prop(`rightmodeler ${value.code}`)}::${data(`${value.message} Remedy: ${value.remedy ?? ""}`)}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ jobs:
79
+ init:
80
+ if: >-
81
+ github.event_name == 'push' ||
82
+ (github.event_name == 'schedule' && github.event.schedule == '17 5 * * 1') ||
83
+ (github.event_name == 'workflow_dispatch' && inputs.command == 'init')
84
+ runs-on: ubuntu-latest
85
+ timeout-minutes: 60
86
+ steps:
87
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
88
+ with:
89
+ fetch-depth: 0
90
+ persist-credentials: false
91
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
92
+ with:
93
+ node-version: 24
94
+ - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
95
+ with:
96
+ path: .rightmodeler
97
+ key: rightmodeler-store-${{ github.run_id }}-${{ github.run_attempt }}
98
+ restore-keys: rightmodeler-store-
99
+ - id: init
100
+ name: Find and prove cheaper models
101
+ env:
102
+ RIGHTMODELER_PROVIDER_BASE_URL: ${{ vars.RIGHTMODELER_PROVIDER_BASE_URL }}
103
+ RIGHTMODELER_PROVIDER_API_KEY: ${{ secrets.RIGHTMODELER_PROVIDER_API_KEY }}
104
+ run: |
105
+ out="$RUNNER_TEMP/rightmodeler"
106
+ mkdir -p "$out"
107
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" --version
108
+ set +e
109
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" init \
110
+ --traces "$RIGHTMODELER_TRACES" \
111
+ --base-url "$RIGHTMODELER_PROVIDER_BASE_URL" \
112
+ --api-key-env RIGHTMODELER_PROVIDER_API_KEY \
113
+ --max-cost-usd "$RIGHTMODELER_MAX_COST_USD" \
114
+ --output jsonl --repo "$GITHUB_WORKSPACE" \
115
+ >"$out/init.jsonl" 2>"$out/init.err"
116
+ code=$?
117
+ set -e
118
+ node -e "$RM_ANNOTATE" "$out/init.jsonl" "$out/init.err"
119
+ report="$GITHUB_WORKSPACE/.rightmodeler/project/reports/report.md"
120
+ if [ -f "$report" ]; then
121
+ cp "$report" "$out/report.md"
122
+ cat "$report" >>"$GITHUB_STEP_SUMMARY"
123
+ fi
124
+ case "$code" in
125
+ 0) echo "recommendation=false" >>"$GITHUB_OUTPUT" ;;
126
+ 1)
127
+ echo "recommendation=true" >>"$GITHUB_OUTPUT"
128
+ echo "::notice title=rightmodeler::A proven swap is ready. Run this workflow with command=apply to open the draft pull request."
129
+ ;;
130
+ *)
131
+ echo "::error title=rightmodeler::init exited $code; the annotations above name the cause and the fix."
132
+ exit 1
133
+ ;;
134
+ esac
135
+ - if: always()
136
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
137
+ with:
138
+ name: rightmodeler-init
139
+ path: ${{ runner.temp }}/rightmodeler/
140
+ if-no-files-found: ignore
141
+ - if: always()
142
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
143
+ with:
144
+ path: .rightmodeler
145
+ key: rightmodeler-store-${{ github.run_id }}-${{ github.run_attempt }}
146
+
147
+ apply:
148
+ if: github.event_name == 'workflow_dispatch' && inputs.command == 'apply'
149
+ runs-on: ubuntu-latest
150
+ timeout-minutes: 30
151
+ permissions:
152
+ contents: write
153
+ pull-requests: write
154
+ steps:
155
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
156
+ with:
157
+ fetch-depth: 0
158
+ persist-credentials: false
159
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
160
+ with:
161
+ node-version: 24
162
+ - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
163
+ with:
164
+ path: .rightmodeler
165
+ key: rightmodeler-store-${{ github.run_id }}-${{ github.run_attempt }}
166
+ restore-keys: rightmodeler-store-
167
+ - id: apply
168
+ name: Open the draft pull request
169
+ env:
170
+ RIGHTMODELER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
171
+ run: |
172
+ out="$RUNNER_TEMP/rightmodeler"
173
+ mkdir -p "$out"
174
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" --version
175
+ run_apply() {
176
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" apply "$@" \
177
+ --owner "$GITHUB_REPOSITORY_OWNER" \
178
+ --github-repo "${GITHUB_REPOSITORY#*/}" \
179
+ --github-base-url "$GITHUB_API_URL" \
180
+ --github-token-env RIGHTMODELER_GITHUB_TOKEN \
181
+ --output json --repo "$GITHUB_WORKSPACE"
182
+ }
183
+ for mode in dry-run apply; do
184
+ set +e
185
+ if [ "$mode" = "dry-run" ]; then
186
+ run_apply --dry-run >"$out/$mode.json" 2>"$out/$mode.err"
187
+ else
188
+ run_apply >"$out/$mode.json" 2>"$out/$mode.err"
189
+ fi
190
+ code=$?
191
+ set -e
192
+ node -e "$RM_ANNOTATE" "$out/$mode.json" "$out/$mode.err"
193
+ if [ "$code" -ne 0 ]; then
194
+ echo "::error title=rightmodeler::apply ($mode) exited $code; the annotations above name the cause and the fix."
195
+ exit 1
196
+ fi
197
+ done
198
+ pr="$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).prNumber' "$out/apply.json")"
199
+ echo "pr-number=$pr" >>"$GITHUB_OUTPUT"
200
+ echo "Draft pull request #$pr is open for review. rightmodeler never merges it." >>"$GITHUB_STEP_SUMMARY"
201
+ - if: always()
202
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
203
+ with:
204
+ name: rightmodeler-apply
205
+ path: ${{ runner.temp }}/rightmodeler/
206
+ if-no-files-found: ignore
207
+ - if: always()
208
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
209
+ with:
210
+ path: .rightmodeler
211
+ key: rightmodeler-store-${{ github.run_id }}-${{ github.run_attempt }}
212
+
213
+ watch:
214
+ if: >-
215
+ (github.event_name == 'schedule' && github.event.schedule == '43 */6 * * *') ||
216
+ (github.event_name == 'workflow_dispatch' && inputs.command == 'watch')
217
+ runs-on: ubuntu-latest
218
+ timeout-minutes: 30
219
+ permissions:
220
+ contents: read
221
+ pull-requests: write
222
+ checks: read
223
+ statuses: read
224
+ steps:
225
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
226
+ with:
227
+ fetch-depth: 0
228
+ persist-credentials: false
229
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
230
+ with:
231
+ node-version: 24
232
+ - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
233
+ with:
234
+ path: .rightmodeler
235
+ key: rightmodeler-store-${{ github.run_id }}-${{ github.run_attempt }}
236
+ restore-keys: rightmodeler-store-
237
+ - id: watch
238
+ name: Reconcile open swap pull requests
239
+ env:
240
+ RIGHTMODELER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
241
+ run: |
242
+ out="$RUNNER_TEMP/rightmodeler"
243
+ mkdir -p "$out"
244
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" --version
245
+ set +e
246
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" status --output json \
247
+ --repo "$GITHUB_WORKSPACE" >"$out/status.json" 2>"$out/status.err"
248
+ code=$?
249
+ set -e
250
+ node -e "$RM_ANNOTATE" "$out/status.err"
251
+ if [ "$code" -ne 0 ]; then
252
+ echo "::error title=rightmodeler::status exited $code"
253
+ exit 1
254
+ fi
255
+ failed=0
256
+ for pr in $(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).pullRequests.map((entry) => entry.prNumber).join(" ")' "$out/status.json"); do
257
+ set +e
258
+ npx --yes "rightmodeler@${RIGHTMODELER_VERSION}" watch --pr "$pr" \
259
+ --owner "$GITHUB_REPOSITORY_OWNER" \
260
+ --github-repo "${GITHUB_REPOSITORY#*/}" \
261
+ --github-base-url "$GITHUB_API_URL" \
262
+ --github-token-env RIGHTMODELER_GITHUB_TOKEN \
263
+ --output json --repo "$GITHUB_WORKSPACE" \
264
+ >"$out/watch-$pr.json" 2>"$out/watch-$pr.err"
265
+ code=$?
266
+ set -e
267
+ node -e "$RM_ANNOTATE" "$out/watch-$pr.json" "$out/watch-$pr.err"
268
+ case "$code" in
269
+ 0) ;;
270
+ 1) echo "::notice title=rightmodeler::watch acted on pull request #$pr" ;;
271
+ 2)
272
+ held="$(node -p 'try { JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).status } catch { "" }' "$out/watch-$pr.json")"
273
+ if [ "$held" = "lock_held" ]; then
274
+ echo "::warning title=rightmodeler::another watcher holds the lock for pull request #$pr; the next run retries"
275
+ else
276
+ failed=1
277
+ fi
278
+ ;;
279
+ *) failed=1 ;;
280
+ esac
281
+ done
282
+ exit "$failed"
283
+ - if: always()
284
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
285
+ with:
286
+ name: rightmodeler-watch
287
+ path: ${{ runner.temp }}/rightmodeler/
288
+ if-no-files-found: ignore
289
+ - if: always()
290
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
291
+ with:
292
+ path: .rightmodeler
293
+ key: rightmodeler-store-${{ github.run_id }}-${{ github.run_attempt }}
294
+ ```
295
+
296
+ ## How it behaves
297
+
298
+ - **The store.** `.rightmodeler/` lives in the Actions cache. Each run saves it under a new key and restores the newest `rightmodeler-store-` entry. GitHub removes entries unused for 7 days, and the 6-hourly watch keeps the store in use. Anyone with read access to the repository can read cache contents and uploaded artifacts, so use this workflow in private repositories.
299
+ - **One run at a time.** All runs share one concurrency group. If a run is already waiting, a newly queued run replaces it, so dispatch again if yours was replaced.
300
+ - **Annotations.** Every error and warning the CLI prints becomes an annotation that names its code, and errors also carry the remedy. `init` adds the report to the job summary, and every job uploads its output files as an artifact.
301
+ - **Stale evidence.** If `main` moves between `init` and `apply`, `apply` refuses with `stale_evidence`. Run `init` again.
302
+ - **Other branches.** A dispatch from another branch works on that branch. It starts from the default branch's store, saves its own copy that only that branch's runs restore, and `apply` opens the draft against that branch.
303
+ - **The token.** The draft is authored by `github-actions[bot]`, and the owners of the swapped files are requested as reviewers. GitHub starts no workflow for a push made with `GITHUB_TOKEN`. For a pull request that `GITHUB_TOKEN` opens, GitHub creates the `pull_request` workflow runs in an approval-required state, and a person with write access starts them with "Approve workflows to run" on the pull request.
304
+ - **Schedules.** GitHub can delay scheduled runs at busy times, and disables schedules in public repositories after 60 days without activity.
305
+
306
+ ## Optional: a GitHub App token
307
+
308
+ Not tested live: rightmodeler's acceptance runs of this workflow use `GITHUB_TOKEN` only. A GitHub App installation token lets the draft's `pull_request` workflows start without approval, and the draft is authored by `<app-slug>[bot]`. To use one:
309
+
310
+ 1. Create a GitHub App with the permissions in [GitHub](github.md) and install it on the repository.
311
+ 2. Store its client ID in the repository variable `RIGHTMODELER_APP_CLIENT_ID` and its private key in the repository secret `RIGHTMODELER_APP_PRIVATE_KEY`.
312
+ 3. In the `apply` and `watch` jobs, add this step before the rightmodeler step, and change that step's `RIGHTMODELER_GITHUB_TOKEN` to `${{ steps.app-token.outputs.token }}`:
313
+
314
+ ```
315
+ - id: app-token
316
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
317
+ with:
318
+ client-id: ${{ vars.RIGHTMODELER_APP_CLIENT_ID }}
319
+ private-key: ${{ secrets.RIGHTMODELER_APP_PRIVATE_KEY }}
320
+ ```
321
+
322
+ ## Cloud Mode B
323
+
324
+ To confirm in Vercel Sandbox, add `--modeb-config <file>` to the `init` command and map the `VERCEL_TOKEN`, `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID` secrets into that step's `env`. See [Mode B](modeb.md).
325
+
326
+ ## Upgrading
327
+
328
+ Change `RIGHTMODELER_VERSION`. Each rightmodeler step first runs the CLI with `--version`, so a version npm cannot install fails the step with npm's error in its log instead of being mistaken for a rightmodeler exit code. Each release's copy of this guide pins that release, and `rightmodeler docs github-actions` prints the copy for the installed version.
package/docs/github.md ADDED
@@ -0,0 +1,110 @@
1
+ # GitHub
2
+
3
+ Three commands talk to GitHub. `apply` opens a draft pull request that changes model identifiers only and carries an evidence table. `watch` reconciles one of those pull requests per run. `rollback` opens a draft pull request that restores a merged swap. The CLI never merges a pull request: a person reviews and merges.
4
+
5
+ ## Tokens
6
+
7
+ Pass the name of the environment variable that holds the token with `--github-token-env`, never the token itself.
8
+
9
+ | Token | What works |
10
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | GitHub App installation token (recommended) | `apply` and `watch` fully. |
12
+ | Classic personal access token, or `gh auth token`, with the `repo` scope | `apply` and `watch` fully. |
13
+ | Fine-grained personal access token | `apply` fully. `watch` works but cannot read check runs, so each pass prints the `github_checks_unavailable` warning and uses commit statuses only. |
14
+ | GitHub Actions `GITHUB_TOKEN` | `apply` and `watch` when the job grants `contents: write`, `pull-requests: write` and `statuses: read` (add `checks: read` for check runs) and the repository allows GitHub Actions to create pull requests. CI on the pull requests it opens waits for approval, which an App installation token avoids. |
15
+
16
+ Give a GitHub App these repository permissions: Contents read and write, Pull requests read and write, Checks read, Commit statuses read, and Metadata read. Pull requests it opens are authored by `<app-slug>[bot]`.
17
+
18
+ Give a fine-grained personal access token access to the repository with Contents read and write, Pull requests read and write, Commit statuses read, and Metadata read. GitHub offers no check-run permission for these tokens.
19
+
20
+ Commenting on a pull request needs only Pull requests write, so neither token needs the Issues permission.
21
+
22
+ For a ready workflow that uses `GITHUB_TOKEN`, see [GitHub Actions](github-actions.md).
23
+
24
+ ## API host
25
+
26
+ `--github-base-url` defaults to `https://api.github.com`. For GitHub Enterprise Server, pass its API URL, such as `https://github.example.com/api/v3`. Enterprise Server 3.21 or newer is needed, because the CLI sends REST API version `2026-03-10` and older servers answer every call with HTTP 400.
27
+
28
+ `--github-repo` defaults to the name of the directory given to `--repo` on `apply`, `watch` and `rollback`. Pass it when the directory name differs from the repository name on GitHub.
29
+
30
+ ## Apply
31
+
32
+ `apply` needs:
33
+
34
+ - a completed `init` run whose report recommends at least one swap;
35
+ - local `HEAD` on a branch and equal to the evidence revision, and that branch on GitHub at the same revision (the branch becomes the pull request base);
36
+ - no uncommitted change to any file the swap touches.
37
+
38
+ `--dry-run` runs every check and reads GitHub but writes nothing. Its result lists the branch, title, body, files and reviewers it would use. The reviewer list is shown before the pull request author is removed, because the author is known only once the pull request exists.
39
+
40
+ Reviewers:
41
+
42
+ - Owners of each swapped file come from the first of `.github/CODEOWNERS`, `CODEOWNERS` and `docs/CODEOWNERS` that exists, and the last matching rule wins.
43
+ - A file with no matching rule falls back to its three most recent `git blame` authors, matched to GitHub users by commit email. Blame needs full history, so fetch with depth 0 in CI.
44
+ - At most five users and five teams are requested, and never the pull request author.
45
+ - If GitHub rejects the batch with HTTP 422, each reviewer is requested alone and the rejected ones are dropped.
46
+ - Team reviewers need a repository owned by an organization, and every reviewer needs access to the repository.
47
+ - GitHub does not request code owners on draft pull requests by itself, so every review request on the draft comes from `apply`.
48
+
49
+ The branch is `<prefix>swap-<family>-<digest prefix>`, where the prefix is the one most common among the repository's recent branches, or `rightmodeler/` when no branch has one. The title is `perf(models): swap <families>` when the repository uses conventional commits, and `Swap <families> models` otherwise. The body is the repository's pull request template, if it has one, followed by the `## Rightmodeler evidence` table: revision, corpus version, and per family the decision, evaluators, cascade status, worst-case bound, models, cost per case, latency, caps and case IDs. Case IDs are SHA-256 digests of the replayed cases, never prompts. With `--code-graph <path>`, a `## Code context (Graphify)` section for the swapped call sites follows the table; the owners it lists are never requested as reviewers.
50
+
51
+ Rerunning `apply` for the same evidence returns the same open pull request with `status: "existing"` and requests reviewers again only if the first attempt left no record of them.
52
+
53
+ ## Refusal codes
54
+
55
+ A refusal exits `1` and prints a result with `status: "refused"` and one or more `reasons`, each with a `code`, a `message` and a `detail`.
56
+
57
+ Apply:
58
+
59
+ - `no_confirmed_recommendation`: no recommended family has confirmed or not-required cascade evidence.
60
+ - `release_gate_failed`: at least one release gate is not green.
61
+ - `inconsistent_evidence`: the selected families do not share one evidence revision, corpus and gate policy.
62
+ - `previously_rejected`: this evidence and swap set was previously rejected and needs new evidence before it can be proposed again.
63
+ - `stale_evidence`: the evidence revision does not match the repository `HEAD`, or the remote base moved beyond it; re-prove before applying.
64
+ - `detached_head`: the repository has no current branch to use as the pull request base.
65
+ - `dirty_worktree`: a file the swap touches has uncommitted changes; commit or stash them before applying.
66
+ - `stale_location`: a proposed swap no longer matches its scan-time file digest or no longer has one fresh source location.
67
+ - `diff_lint_failed`: the proposed diff changes something other than model identifiers.
68
+ - `formatter_blocked`: the repository's formatter changed content outside the proposed swap.
69
+ - `host_conventions_unreadable`: one or more repository instructions could not be read unambiguously.
70
+ - `invalid_repository_revision`: the evidence revision is not a 40- or 64-character lowercase hex object ID.
71
+ - `apply_branch_unowned`: the swap branch exists without a recorded apply start.
72
+ - `apply_branch_scope_mismatch`: the existing swap branch changes files outside its declared scope.
73
+ - `apply_resume_state_mismatch`: the recorded pre-apply state does not match the resumed change.
74
+ - `apply_restore_failed`: after a failed apply, a file on the swap branch could not be restored.
75
+
76
+ Rollback:
77
+
78
+ - `missing_remediation_evidence`: the pull request has no valid recorded apply evidence.
79
+ - `original_pr_not_merged`: only a merged swap pull request can be rolled back.
80
+ - `pre_apply_revision_unavailable`: the recorded pre-apply file revision is unavailable or does not match its digest.
81
+ - `post_apply_digest_mismatch`: the affected files no longer match the recorded post-apply state.
82
+ - `rollback_branch_unowned`: the rollback branch exists without a recorded rollback start or base revision.
83
+ - `rollback_branch_scope_mismatch`: the existing rollback branch changes files outside the recorded scope.
84
+ - `rollback_restore_mismatch`: the rollback did not restore the recorded pre-apply state.
85
+ - `rollback_restore_failed`: after a failed rollback, a file could not be restored to the base state.
86
+
87
+ ## Watch
88
+
89
+ Each `watch` run makes one pass over one pull request under a lock kept in the store, so overlapping runs do not act twice. Run it on a schedule or from repository events. A pass:
90
+
91
+ - records a merge and ends watching;
92
+ - records a close without merge as a rejection and ends watching, after which `apply` refuses that swap with `previously_rejected`;
93
+ - answers each human review and comment once with the stored evidence for the families it names, else for the families in the file it comments on, else for every family in the pull request;
94
+ - marks families for re-proof when a reviewer requests changes, or when the base branch changes a swapped file, and says so in a comment; the re-proof itself happens on the next pipeline run;
95
+ - on a failing check run or commit status, comments once, and if a check with the same name fails again under a new run on the same head commit, closes the pull request.
96
+
97
+ Exit codes:
98
+
99
+ - `0`: nothing needed doing.
100
+ - `1`: the pass took an action, listed in the result's `actions`.
101
+ - `2`: another watcher holds the lock, or the store has no completed run. With `--output json` or `jsonl`, a held lock prints a result with `"status":"lock_held"` on standard output, while a missing run prints an error with code `stage_not_completed` on standard error and nothing on standard output.
102
+ - `10` or greater: runtime failure.
103
+
104
+ When GitHub refuses to list check runs with HTTP 403, the pass still reconciles reviews, comments, commit statuses, merges and base-branch changes, and prints the `github_checks_unavailable` warning. Fine-grained personal access tokens always cause it. To include check runs, use a GitHub App installation token with Checks read or a classic token with the `repo` scope.
105
+
106
+ ## Rollback
107
+
108
+ `rollback --pr <number>` opens a draft pull request that restores the pre-apply contents of the files a merged swap changed. It refuses unless the original pull request merged, and a rerun returns the same rollback pull request.
109
+
110
+ See [Exit codes](exit-codes.md) and [Commands](commands.md) for every option.
package/docs/modeb.md CHANGED
@@ -6,6 +6,7 @@ Mode B runs confirmation cases inside a container when a recommendation can affe
6
6
  {
7
7
  "version": "1",
8
8
  "image": "my-agent:latest",
9
+ "backend": "docker",
9
10
  "appSpec": {
10
11
  "mountPath": ".",
11
12
  "command": ["node", "/rightmodeler/app/driver.mjs", "{caseFile}"],
@@ -26,6 +27,39 @@ Mode B runs confirmation cases inside a container when a recommendation can affe
26
27
  - `appSpec.command` is a non-empty array of non-empty arguments. At least one argument must contain `{caseFile}`; the harness replaces every occurrence with the in-container case file path.
27
28
  - `appSpec.installCommand` is optional. When present, it is a non-empty array of non-empty arguments run before the workload.
28
29
  - `stepMap` maps at least one canonical scanner step ID to the runtime step header emitted by the application. Runtime headers must be unique.
30
+ - `backend` is optional and is either `"docker"` (the default) or `"cloud"`. The cloud backend runs each case in a remote sandbox, so `image` must name an image that sandbox platform can pull, and the run fails before any case starts when the sandbox SDK or its credentials are absent.
29
31
  - `confirmMaxRunSets` is optional and must be a non-negative integer.
30
32
 
33
+ ## Runtime contract
34
+
35
+ - The container receives `OPENAI_BASE_URL=http://127.0.0.1:8787/v1`, a placeholder `OPENAI_API_KEY`, and the `RM_RUN_ID`, `RM_CASE_ID`, and `RM_EXECUTION_ID` identifiers.
36
+ - `/rightmodeler/scratch/driver/case.json` contains `{ "caseId", "input", "headers"? }`. The `headers` field is omitted when the case has no headers.
37
+ - Every model request must carry `x-rm-step` exactly once. Its value is the runtime step header selected through `stepMap`.
38
+ - Every model request must carry `x-rm-call`. Use one ID per logical call and reuse that ID when the SDK retries the call.
39
+ - The request body must be a JSON object with a non-empty `model` string.
40
+ - On a step the candidate does not replace, the request must name that step's current model by the id the provider catalog lists for it. The proxy holds prices only for the models the run's steps call, so a request for any other model is recorded as lost with `missing_pricing` and never forwarded.
41
+ - `max_completion_tokens` or `max_tokens` is accepted when present. When both are absent, the proxy reserves against the catalog maximum output when known, or 4096 tokens otherwise, and does not add a limit to the forwarded body.
42
+ - Streamed requests are sent with `stream_options.include_usage: true` and metered from the trailing usage chunk. A stream without a usage chunk is charged at its reservation.
43
+ - The last non-empty stdout line must be `{ "runId", "caseId", "executionId", "finalOutput" }`.
44
+ - A request is recorded as lost and never forwarded for `missing_correlation`, `duplicate_step_correlation`, `request_too_large` at 10 MiB, `malformed_json`, `invalid_request`, or `missing_pricing`.
45
+ - A case with any lost request is a lost execution. Lost reason counts are reported on the Mode B result.
46
+ - A request that the case lease cannot cover receives HTTP 402. The case is blocked on budget without an execution fact and is retried on rerun.
47
+ - If the host cannot observe a container exit within the configured timeout plus ten seconds, it force-removes the container and records the case as lost under `container_lifecycle`.
48
+ - The `docker` CLI must reach the Docker daemon. A missing daemon, a failed egress listener, or a failed container launch blocks affected cases with a named reason instead of recording executions, so a rerun retries them.
49
+ - The in-container proxy runs on both backends. The workload always reaches it at `OPENAI_BASE_URL`, and it meters the case lease and records every attempt. Only the hop after it differs: the Docker backend forwards to a host listener over `host.docker.internal`, while the cloud backend forwards straight to the provider and the sandbox platform's egress firewall attaches the model credential in flight. The credential never enters the sandbox on either backend.
50
+ - The workload is killed at the configured timeout by both the host and an in-container deadline.
51
+
52
+ ## Cloud backend
53
+
54
+ - The cloud backend runs each confirmation case in a short-lived Vercel Sandbox microVM in your own Vercel project. It is separate from the Vercel AI Gateway: the `VERCEL_*` variables only authorize creating sandboxes, and the model credential is always the variable named by `--api-key-env`, whatever the provider.
55
+ - Install: `npx rightmodeler` installs `@vercel/sandbox` as an optional dependency. Installing with `--omit=optional` leaves it out; the CLI still runs and the cloud backend reports `modeb_cloud_unavailable`.
56
+ - Credentials: either `VERCEL_OIDC_TOKEN` (from `vercel link` then `vercel env pull`; it expires after 12 hours) or all of `VERCEL_TOKEN`, `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`. Use the access token in CI.
57
+ - Image: `image` must be a Vercel Container Registry reference or a managed image such as `vercel/sandbox/node:24`; a Docker Hub name does not work, and a custom image's entrypoint and command do not run. Commands run as the image's default user, which is not root (`ubuntu` on `vercel/sandbox/node:24`). An image the platform cannot start blocks its case with `launch-failed`, so a rerun retries it.
58
+ - Provider: the base URL must be HTTPS, because the sandbox firewall matches the provider host by TLS server name before it attaches the credential. Its path is kept, so a base URL such as `https://openrouter.ai/api/v1` reaches the same endpoints as on the Docker backend.
59
+ - Credential handling: the firewall adds the model credential to requests for the provider host only, and its policy lets every other host through, so this is credential brokering, not an egress allowlist.
60
+ - Metering: the in-sandbox proxy asks the provider for uncompressed responses so it can read usage from every answer. There is no host listener to mark answers of its own, so every HTTP answer is attributed to the provider, including one the platform firewall produces itself; a connection that fails is attributed to the network path and its case is recorded as a lost execution.
61
+ - Timing: the 60 second case deadline starts when the workload command starts and includes `appSpec.installCommand`; every case starts a fresh microVM, so bake dependencies into the image. The sandbox itself lives for the deadline plus 60 seconds, which also bounds how long an interrupted run can leave one running.
62
+ - Cleanup: sandboxes are created non-persistent and deleted after each case.
63
+ - Scoring: sandboxes never score. The host judges every case; sandbox output is only the candidate text.
64
+
31
65
  See [Commands](commands.md) for where `--modeb-config` is accepted and [Exit codes](exit-codes.md) for blocked or failed runs.
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "rightmodeler",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
+ "description": "Find and prove safe model substitutions from the agent traces you already have.",
5
+ "homepage": "https://www.rightmodeler.com",
4
6
  "type": "module",
5
7
  "bin": {
6
8
  "rightmodeler": "./dist-bundle/cli.js"
@@ -13,6 +15,9 @@
13
15
  "engines": {
14
16
  "node": ">=24"
15
17
  },
18
+ "optionalDependencies": {
19
+ "@vercel/sandbox": "^3.3.0"
20
+ },
16
21
  "license": "MIT",
17
22
  "repository": {
18
23
  "type": "git",