Files
gstack/docs/eval-calls.md
T
Garry Tanandcapy-ai[bot] 7edaf4f50a v1.91.98.0 fix: plan obligations meet repo gates; priced paid gates; machine-proved restamps (field wave C, items 14-17) (#3166)
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(pregate): price_command config key for the non-executing call plan a paid gate is priced from

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(pregate): repo guards a lane must pass, verified launch Bun and per-file serial runs in lanes, unknown flags exit 2

The guards check runs each .gstack/pregate.json guard whose globs match a
touched file in its own process with its own timeout (PREGATE_GUARD_FAILED,
never downgradable, incomplete without --allow-repo-commands). The lanes
runner and guards get the first Bun in $BUN_INSTALL/bin or ~/.bun/bin that
satisfies gstack's and the project's engines.bun; serial_patterns files run
one process each. price_file replaces the unreleased price_command key: a
paid gate is priced from a call-plan data file, never by running a command.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pricing): Sonnet 5.5, Gemini 3.x and gbrain embedding rows from fetched pages; Opus 4.7 at its current list price

Each new row carries its source URL and fetch date (2026-10-12). Embedding
models bill input only, so they live in EMBEDDING_PRICING beside PRICING
(eval arms never resolve to them); pricingRow also accepts gbrain's
provider:model ids (voyage:voyage-4). claude-opus-4-7 moves from 15/75 to the
5/25 the Anthropic pricing page lists; benchmark-runner's expected costs follow.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): price-gate prices a paid gate from a call-plan data file, never executing it

gstack-eval-plan price-gate --gate "<exact command>" reads a GSTACK_EVAL_CALLS
v1 file (--plan-file, else the base policy's price_file at HEAD), checks its
binding (gate command string equality, head with only the plan file changed
since, every input's sha256 at HEAD), prices each call purpose (aggregate
totals) and each one-call reservation, and writes a receipt binding the price
to the gate, head, inputs and plan bytes. Unpriced models print unknown, never
zero (EVAL_PRICE_MISSING); a missing plan or no call lines is
EVAL_GATE_UNPRICED unless --allow-unpriced records an owner override; over
--cap-usd / --smoke-cap-usd is EVAL_GATE_OVER_CAP; malformed or partial files
refuse EVAL_DRY_RUN_FAILED and stale ones EVAL_PLAN_STALE. dry-run --cap adds
the same reservation-fits-cap check. Wire format, receipt and the reference
emitter (formatEvalCalls): docs/eval-calls.md.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): the approval step prices every paid gate with price-gate

Step 4 runs gstack-eval-plan price-gate for each paid gate the plan names and
puts its GATE_PRICE line in the ask; the dry-run checklist documents
reservation-fits-cap under --cap.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(sealed): sealed-output guard over every published commit, in the pre-push hook and gstack-pregate sealed

A repo with a sealed key in .gstack/pregate.json (base branch union the
pushed commit, so a push can tighten but never relax) refuses publishing
sealed paths, transcripts, quoted answers, seeds or local home paths to a
public or unknown destination (SEALED_OUTPUT_REFUSED); private warns.
Path rules see every added, renamed or copied path in each published
commit (git rev-list <local> --not --remotes=<remote>), text rules each
commit's added lines; waivers only from the base branch allow file; a
malformed policy fails closed; visibility caches public 24 h, private 1 h.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(eval-plan): only an undeclared plan is unpriced; a missing price_file is stale, a bad base policy refuses

price-gate's EVAL_GATE_UNPRICED (and --allow-unpriced) now covers only no
plan declared at all or a plan with zero call lines. A declared price_file
missing from the HEAD commit is EVAL_PLAN_STALE (exit 1, not overridable); an
unresolvable origin/<base> is POLICY_SOURCE_UNAVAILABLE and an invalid
.gstack/pregate.json is PREGATE_CONFIG_INVALID (both exit 3).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(restamp): the repo's stamp rule (stamp_files, literal stamp_patterns) and restamp_command in the staging worktree

stamp_files is an allowlist applied before every stamp rule; stamp_patterns
are bounded literal {version} templates shared by the restamp writer and the
tree receipt. restamp_command runs first in the staging worktree with the
target version (argument and GSTACK_RESTAMP_VERSION), needs a clean caller
tree, skips gstack's version writer and keeps output confinement; a failure
or a wrong version writes nothing. migrations { dir, id } is the policy key
gstack-pregate regate reads.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(pregate): gstack-pregate regate --against <gated-head> with per-lane reuse and migration collisions

Reuse comes only from a tree receipt evaluated now, with a bundle produced
on the gated tree, the current execution identity and an unmoved, known
predecessor head. Each lane prints reusable/rerun/not-reusable; REGATE:
not-needed needs stamp-only, every bundle and --require lane reusable (a
stamp-sensitive lane reruns) and a finished migration lookup. Migration
numbers the branch adds are checked against the base tree and every other
open PR (gh pr list --limit 1000; 100-file lists refetched with gh api
--paginate); any lookup that cannot finish is unknown and blocks
not-needed (MIGRATION_COLLISION). Bare --against routes to the verb.
docs/pregate.md gains the regate section and the gbrain worked configs.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(contract): lanes run gstack-pregate (and the sealed guard where configured) before done; handoff carries the gstack trailer and PREGATE: line

The coordinator contract gains a 'Before done' paragraph and the handoff
order GSTACK_RESULT, gstack: ok|fail <rev>, PREGATE:, gate summary; the
spec section is regenerated and docs/unattended.md states the same rule.
A new test pins the terms and their order; the byte-identity pins stay.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.98.0: pregate guards, priced paid gates, regate and the sealed-output guard

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: price-gate --gate-file keeps free text out of the /eval-plan fence; shortest dependency traces; bounded test spawns

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(windows): curate out the new pregate/restamp tests that spawn bin/ shebang scripts through runBin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-13 15:38:41 +00:00

193 lines
11 KiB
Markdown

# Eval call plans: pricing a paid gate without running it
A paid gate (a benchmark, an eval smoke, any command that calls a model
provider for money) is priced before anyone approves it. gstack does not run
the gate, or any other repository command, to learn its cost. The repository
commits a **call-plan file**: plain data that names the exact gate command, the
commit and inputs it was computed from, and one line per call purpose with its
model and token counts. `gstack-eval-plan price-gate` reads that file, checks
that it still describes what is being approved, prices it from
`lib/pricing.ts`, and writes a receipt. Plan: field wave CD2
(`docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md`, gate decision d5b as amended).
```bash
gstack-eval-plan price-gate --gate-file <file> --cap-usd 20 [--smoke-cap-usd 2] [--plan-file <file>]
```
`--gate-file` reads the exact gate command from a file (one trailing newline is
dropped), so a command with quotes or `$` never passes through a shell string;
`--gate "<command>"` takes it inline. Give one of the two.
## Where the file comes from
1. `--plan-file <file>`: an operator-given file, read from disk.
2. Otherwise `price_file` in `.gstack/pregate.json` as committed on
`origin/<base>` (`--base`, default origin/HEAD's branch): a repo-relative
path whose bytes are read from the **HEAD commit** (`git show HEAD:<path>`),
never from the working tree.
Nothing in the file is executed: not the gate command, not a precondition,
not an input path. The file is parsed as text.
## File format, wire version v1
```
# gstack eval call plan, wire format v1 (docs/eval-calls.md); priced by `gstack-eval-plan price-gate`, never executed
GSTACK_EVAL_GATE v1: bun run eval:synopsis --questions 50 --arms opus,sol
GSTACK_EVAL_HEAD v1: 0123456789abcdef0123456789abcdef01234567
GSTACK_EVAL_INPUT v1: sha256=<64 hex> path=evals/synopsis/questions.jsonl
GSTACK_EVAL_PRECONDITION: ANTHROPIC_API_KEY and OPENAI_API_KEY set at run time
GSTACK_EVAL_CALLS v1: purpose=answer model=anthropic/claude-opus-5-5 calls=100 in_tokens=400000 out_tokens=200000 max_out_tokens=8192 scope=full
GSTACK_EVAL_CALLS v1: purpose=judge model=gpt-6.1-sol calls=100 in_tokens=300000 out_tokens=50000 max_out_tokens=2048 scope=full
GSTACK_EVAL_CALLS v1 end: lines=2
```
| Line | Count | Meaning |
|---|---|---|
| `GSTACK_EVAL_GATE v1: <command>` | exactly 1 | the exact gate command this plan prices; compared to `--gate` by string equality |
| `GSTACK_EVAL_HEAD v1: <sha>` | exactly 1 | the full commit sha the plan was computed at |
| `GSTACK_EVAL_INPUT v1: sha256=<hex> path=<path>` | 0 or more | each input the counts came from (rows, datasets, prompts), repo-relative, no `..` |
| `GSTACK_EVAL_PRECONDITION: <text>` | 0 or more | what must hold at run time; every one is echoed as `GATE_PRECONDITION:` |
| `GSTACK_EVAL_CALLS v1: …` | 0 or more | one line per call purpose (unique `purpose` + `model`) |
| `GSTACK_EVAL_CALLS v1 end: lines=<n>` | exactly 1, last | `n` = the number of call lines; a missing or miscounted end line means a partial producer and refuses |
Blank lines and lines starting with `#` are ignored. Every other line must be
one of the above; an unknown kind, an unknown version (`v2`), a missing or
extra key, a duplicate key, a non-integer count, `calls=0`, or any content
after the end line refuses with `EVAL_DRY_RUN_FAILED`, naming the line.
### Call line keys
| Key | Meaning |
|---|---|
| `purpose` | name of the call purpose (`[A-Za-z0-9._:-]+`), for example `answer`, `judge`, `embed` |
| `model` | model id; a provider prefix (`anthropic/`, `openai:`, `voyage:`) is accepted |
| `calls` | number of calls for this purpose (at least 1) |
| `in_tokens` | **aggregate** input tokens across all of this purpose's calls |
| `out_tokens` | **aggregate** expected output tokens across all of this purpose's calls |
| `max_out_tokens` | the **per-call** output reservation (the `max_tokens` the call sends) |
| `scope` | `full` (the counted run) or `smoke` (a small pre-run check) |
The aggregate totals are what the purpose is expected to cost:
`usd = price(in_tokens, out_tokens)`. The per-call reservation is what one call
can cost at most, which is what a provider or a spend ledger reserves:
`reservation_usd = price(ceil(in_tokens / calls), max_out_tokens)`.
## Binding checks (`EVAL_PLAN_STALE`)
The plan refuses, with no receipt, when it no longer describes what is being
approved:
- the `GSTACK_EVAL_GATE` command differs from `--gate` by even one character;
- the `GSTACK_EVAL_HEAD` commit is not HEAD and any file other than the plan
file itself changed between them (a committed plan file sits one commit on
top of the head it was computed at, so its own commit is allowed);
- an input's sha256 of its bytes in the HEAD commit differs from its
`GSTACK_EVAL_INPUT` line, or the input is not in HEAD;
- the base policy declares a `price_file` that is not in the HEAD commit.
`--allow-unpriced` never overrides these: only a gate with no plan declared at
all, or a plan with zero call lines, is unpriced.
## Pricing outcomes
```
GATE_PLAN: file=evals/gate.calls source=price_file sha256=<hex> head=<sha12> inputs=1 verified
GATE_PRECONDITION: ANTHROPIC_API_KEY and OPENAI_API_KEY set at run time
GATE_CALLS: purpose=answer model=anthropic/claude-opus-5-5 calls=100 usd=5.6 reservation_usd=0.17984 scope=full
GATE_CALLS: purpose=judge model=gpt-6.1-sol calls=100 usd=1.1 reservation_usd=0.02648 scope=full
CHECK: reservation-fits-cap ok (largest one-call reservation $0.1798)
GATE_PRICE: total=6.7 calls=200 purposes=answer,judge receipt=.gstack/tmp/price-gate/price-gate-<gate12>-<head12>.json
```
| Outcome | Exit | Code |
|---|---|---|
| priced, under the caps | 0 | — |
| a model with no row in `lib/pricing.ts`: that row and the total print `unknown`, never zero | 1 | `EVAL_PRICE_MISSING` |
| total above `--cap-usd` | 1 | `EVAL_GATE_OVER_CAP` |
| one call's reservation above `--cap-usd`, or above `--smoke-cap-usd` for a `scope=smoke` row (`CHECK: reservation-fits-cap exceeds (…)`) | 1 | `EVAL_GATE_OVER_CAP` |
| no plan declared (no `--plan-file` and no `price_file` key) or a file with zero call lines | 1 | `EVAL_GATE_UNPRICED` |
| the same with `--allow-unpriced "<reason>"` | 0 | prints `GATE_UNPRICED_OVERRIDE: <reason> (owner-authorized override; not authorization to spend)` and records it |
| malformed or partial file | 1 | `EVAL_DRY_RUN_FAILED` |
| stale binding, or a declared `price_file` missing from HEAD (not overridable) | 1 | `EVAL_PLAN_STALE` |
| `origin/<base>` unresolvable while reading `price_file` | 3 | `POLICY_SOURCE_UNAVAILABLE` |
| `.gstack/pregate.json` on the base is invalid | 3 | `PREGATE_CONFIG_INVALID` |
| unknown flag or missing `--gate` | 2 | usage |
The price is an estimate: the table prices standard text tokens and excludes
the billing categories listed in `lib/pricing.ts` (cache writes, tool fees,
service tiers and long-context surcharges, media tokens, seat-metered runs).
## Receipt
Every outcome past the binding checks writes a JSON receipt to `--out`
(default `<repo>/.gstack/tmp/price-gate/`), named
`price-gate-<gate sha256 12>-<head 12>.json`, and prints its path on the
`GATE_PRICE:` line. `--json` prints the receipt (plus `receipt_path`) instead
of the lines.
| Field | Meaning |
|---|---|
| `schema_version` | `1` |
| `kind` | `gstack-eval-price-gate` |
| `created_at`, `gstack_revision` | when, and which gstack (VERSION and commit) priced it |
| `gate_command`, `gate_sha256` | the approved command and its sha256 |
| `head_sha` | HEAD when priced |
| `plan` | `file`, `source` (`flag`, `price_file`, `none`), `sha256` of the plan file bytes, `declared_head` |
| `inputs` | each `{ path, sha256 }`, verified at HEAD |
| `preconditions` | every precondition line |
| `rows` | each call line plus `usd` and `reservation_usd` (`null` when unpriced) |
| `total_usd`, `calls`, `purposes` | the totals; `total_usd` is `null` when unknown |
| `cap_usd`, `smoke_cap_usd` | the caps passed, or `null` |
| `result`, `code` | `priced`, `price_missing`, `over_cap`, `unpriced`, `unpriced_override`, and the result code |
| `unpriced_reason`, `unpriced_override` | why the gate is unpriced, and the owner's override reason |
| `excluded_billing` | the billing categories the estimate leaves out |
## Reference emitter
A consuming repo writes the file from the same code that builds its calls, so
the counts cannot drift from the run. `formatEvalCalls` in `lib/eval-plan.ts`
is the reference; copy it:
```ts
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const KEYS = ['purpose', 'model', 'calls', 'in_tokens', 'out_tokens', 'max_out_tokens', 'scope'] as const;
type Row = { purpose: string; model: string; calls: number; in_tokens: number; out_tokens: number; max_out_tokens: number; scope: 'full' | 'smoke' };
export function formatEvalCalls(p: { gate: string; head: string; inputs: { path: string; sha256: string }[]; preconditions: string[]; rows: Row[] }): string {
return [
'# gstack eval call plan, wire format v1 (docs/eval-calls.md); priced by `gstack-eval-plan price-gate`, never executed',
`GSTACK_EVAL_GATE v1: ${p.gate}`,
`GSTACK_EVAL_HEAD v1: ${p.head}`,
...p.inputs.map(i => `GSTACK_EVAL_INPUT v1: sha256=${i.sha256} path=${i.path}`),
...p.preconditions.map(t => `GSTACK_EVAL_PRECONDITION: ${t}`),
...p.rows.map(r => `GSTACK_EVAL_CALLS v1: ${KEYS.map(k => `${k}=${r[k]}`).join(' ')}`),
`GSTACK_EVAL_CALLS v1 end: lines=${p.rows.length}`,
].join('\n') + '\n';
}
// Count tokens the way the run will, without calling a provider, then:
const head = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const input = 'evals/synopsis/questions.jsonl';
const sha256 = createHash('sha256').update(readFileSync(input)).digest('hex');
// writeFileSync('evals/gate.calls', formatEvalCalls({ gate, head, inputs: [{ path: input, sha256 }], preconditions, rows }));
```
Hash the inputs as committed (the file at HEAD), produce the plan at a clean
HEAD, commit the plan file on its own commit, and set
`"price_file": "evals/gate.calls"` in `.gstack/pregate.json`.
## Related
- `gstack-eval-plan dry-run --cap <usd>` runs the same reservation-fits-cap
check for an eval's arms: one call (the largest row's input plus the output
budget), priced, must fit under the cap.
- Result codes: [EVAL_GATE_UNPRICED](troubleshooting.md#eval-gate-unpriced),
[EVAL_GATE_OVER_CAP](troubleshooting.md#eval-gate-over-cap),
[EVAL_PLAN_STALE](troubleshooting.md#eval-plan-stale),
[EVAL_PRICE_MISSING](troubleshooting.md#eval-price-missing),
[EVAL_DRY_RUN_FAILED](troubleshooting.md#eval-dry-run-failed).