From 7edaf4f50af79796d14bbb8d9110e8a7ee1e0b52 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 13 Oct 2026 08:38:41 -0700 Subject: [PATCH] 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 "" 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 --not --remotes=), 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/ 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 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 , 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> --- CHANGELOG.md | 20 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/DRIVER.md | 4 +- bin/gstack-doctor-components.sh | 6 + bin/gstack-eval-plan | 62 +++++- bin/gstack-pregate | 102 ++++++++- bin/gstack-redact-prepush | 29 ++- docs/eval-calls.md | 192 ++++++++++++++++ docs/pregate.md | 218 +++++++++++++++++- docs/ship-policy.md | 34 ++- docs/troubleshooting.md | 93 ++++++++ docs/unattended.md | 11 + eval-plan/SKILL.md | 28 ++- eval-plan/SKILL.md.tmpl | 21 +- lib/coordinator-contract.ts | 4 +- lib/eval-plan.ts | 264 +++++++++++++++++++++- lib/pregate/checks/guards.ts | 64 ++++++ lib/pregate/checks/lanes.ts | 38 +++- lib/pregate/config.ts | 62 +++++- lib/pregate/index.ts | 10 +- lib/pregate/regate.ts | 134 +++++++++++ lib/pregate/runtime.ts | 38 ++++ lib/pricing.ts | 44 +++- lib/restamp.ts | 78 +++++-- lib/result-codes.ts | 30 +++ lib/sealed-guard.ts | 276 +++++++++++++++++++++++ lib/ship-policy.ts | 52 +++++ lib/tree-receipt.ts | 25 ++- package.json | 2 +- scripts/lib/windows-curation.ts | 3 + scripts/pr-dependencies.ts | 6 +- spec/sections/gate-and-file.md | 4 +- test/benchmark-runner.test.ts | 14 +- test/fixtures/module-size-ratchet.json | 4 + test/helpers/placeholder-allowlist.ts | 1 + test/lane-coordination.test.ts | 9 + test/pr-dependencies.test.ts | 11 + test/pregate-guards.test.ts | 177 +++++++++++++++ test/pregate-invalidation.test.ts | 2 +- test/pregate-regate.test.ts | 191 ++++++++++++++++ test/pregate-selection.test.ts | 5 +- test/pricing-eval-plan.test.ts | 297 ++++++++++++++++++++++++- test/restamp-command-boundary.test.ts | 69 ++++++ test/sealed-guard.test.ts | 223 +++++++++++++++++++ test/stamp-templates.test.ts | 80 +++++++ 46 files changed, 2946 insertions(+), 95 deletions(-) create mode 100644 docs/eval-calls.md create mode 100644 lib/pregate/checks/guards.ts create mode 100644 lib/pregate/regate.ts create mode 100644 lib/pregate/runtime.ts create mode 100644 lib/sealed-guard.ts create mode 100644 test/pregate-guards.test.ts create mode 100644 test/pregate-regate.test.ts create mode 100644 test/sealed-guard.test.ts create mode 100644 test/stamp-templates.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c87cb4048..9cef95b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.91.98.0] - 2026-10-13 + +**A lane's plan obligations now meet the repo's own gates before it reports done. Paid gates get a price before approval, a restamp is proved stamp-only by machine, and sealed eval output can't ride a push into a public repo.** + +### What this means for you + +- **Repo guards run in `gstack-pregate`.** `.gstack/pregate.json` takes `guards`: `{id, when: [globs], command, fix}`. When a changed file matches `when`, the guard runs in its own process with a timeout, and a failure names the guard and its fix. Guards cover the checks an import graph can't see, such as a serial-test manifest row or CLI goldens that spawn the binary. Required guards can't be downgraded, and without `--allow-repo-commands` the run is `incomplete`, never a pass. The lanes check runs on the verified Bun from the install's launch directory and runs serial test files one process each. Unknown flags exit 2 instead of being ignored. +- **`gstack-eval-plan price-gate` prices a paid gate without running it.** It reads a versioned call-plan file (`--plan-file`, or `price_file` in the base branch's policy) that is bound to the gate command, head and inputs, and prints `GATE_PRICE:` with every precondition the file declares. Nothing runs before approval. A gate that declares no plan is `EVAL_GATE_UNPRICED`; `--allow-unpriced ""` approves it as a recorded owner override, never as permission to spend. A stale or malformed plan file refuses. The new `reservation-fits-cap` check catches a single call whose reservation can't fit under the smoke cap. `/eval-plan` prices every paid gate this way at approval, passing the gate command as a file (`--gate-file`) so a command with quotes or `$` never goes through a shell string. The wire format and a reference emitter are in `docs/eval-calls.md`. +- **Current prices.** Claude Sonnet 5.5, Gemini 3.x Flash and the gbrain embedding models are priced from the providers' pages (source and date in the table), and Claude Opus 4.7 is at its current list price ($5/$25 per million tokens). +- **`gstack-pregate regate --against ` says whether a restamped head needs a new gate.** It computes a fresh tree receipt and prints `STAMP_ONLY:`, one `LANE:` line per gate lane (reusable, rerun or not-reusable), `MIGRATION_COLLISION:` against the base and every other open PR, and `REGATE: needed|not-needed`. `not-needed` requires gate evidence for the gated head, an unchanged execution identity, no stamp-sensitive rerun and no unknown collision lookup. Bare `--against` is an alias. +- **The repo's stamp rule is the stamp rule.** Ship policy takes `stamp_files` (the files allowed to differ) and literal `stamp_patterns` with one `{version}` slot, such as `since: '{version}'`. A pure restamp of a repo whose versions sit in quoted `since:` rows, package manifests or stamp comments now classifies as stamps. `restamp_command` runs the repo's own restamp inside restamp's staging worktree with the target version, on a clean tree; a failure or a wrong-version output writes nothing. The `migrations` key (directory and id pattern) feeds the collision check. +- **A sealed-output guard runs before a push.** In a repo whose `.gstack/pregate.json` has a `sealed` key, the managed pre-push hook and `gstack-pregate sealed` check every commit the push publishes: forbidden paths (binary, empty, renamed and add-then-deleted files included), transcripts, quoted answer markers, seeds and machine-local paths. In a public repo a match refuses with the file, line and rule (`SEALED_OUTPUT_REFUSED`); in a private one it warns. The effective policy is the union of the base branch's and the pushed one, so a push can tighten the policy but never relax it. A malformed policy refuses, and unknown visibility counts as public. Waivers come only from a base-branch allow file. Repos without a `sealed` key see no change. +- **Lane handoffs carry the install and pregate lines.** The coordinator contract now asks a lane to run `gstack-pregate run --allow-repo-commands` (and `gstack-pregate sealed` where configured) before reporting done. Its handoff lists `GSTACK_RESULT`, then `gstack: ok|fail `, then `PREGATE:`, then the gate summary. + +### For contributors + +- `scripts/pr-dependencies.ts` traces breadth-first, so a trace names the shortest chain from a case's touchfiles to a file (selection is unchanged). +- New: `lib/pregate/checks/guards.ts`, `lib/pregate/regate.ts`, `lib/pregate/runtime.ts`, `lib/sealed-guard.ts`; docs `docs/eval-calls.md`, `docs/pregate.md#regate` and `#gbrain-worked-configs`. +- New result codes with troubleshooting anchors include `EVAL_GATE_UNPRICED`, `PREGATE_GUARD_FAILED`, `SEALED_OUTPUT_REFUSED` and `MIGRATION_COLLISION`. +- Pregate and ship-policy validators still refuse unknown keys, so a repo should commit `guards`, `sealed`, `price_file`, `stamp_files`, `stamp_patterns`, `restamp_command` or `migrations` only after its machines run this release. ## [1.91.97.0] - 2026-10-13 **An unattended `/autoplan` run on Capy now closes in its own run directory. Reviews done by Capy subagents bind with one command, a revised plan is reviewed in the same run, and the gate asks fewer, better questions.** diff --git a/VERSION b/VERSION index 1b3816353..0a3f874a1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.97.0 +1.91.98.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 9c0e1f510..6f0e18552 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.97.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.98.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index a5f81fc02..c84124c5d 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -116,4 +116,6 @@ Behavior-change notices. Every user-visible behavior change outside the plan's o Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane that reaches it stops with `status=budget_exhausted` and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as `unknown`, never zero. -Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be. +Before done. A lane runs `gstack-pregate run --allow-repo-commands` on its branch (the unit tier on the changed files plus the repo's guards) and reports done only after it passes. In a repo whose `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push, because a PR tool may not run local hooks. + +Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, the `gstack: ok|fail ` line, the `PREGATE:` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be. diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 1af3c3904..a109f5863 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -174,6 +174,12 @@ gstack_result_anchor() { CONTRIBUTOR_SOURCE_UNAVAILABLE) printf '%s' 'contributor-source-unavailable' ;; BRIEF_INPUT_MISSING) printf '%s' 'brief-input-missing' ;; DRAFT_DIRECTION_UNRESOLVED) printf '%s' 'draft-direction-unresolved' ;; + PREGATE_GUARD_FAILED) printf '%s' 'pregate-guard-failed' ;; + MIGRATION_COLLISION) printf '%s' 'migration-collision' ;; + SEALED_OUTPUT_REFUSED) printf '%s' 'sealed-output-refused' ;; + EVAL_GATE_UNPRICED) printf '%s' 'eval-gate-unpriced' ;; + EVAL_GATE_OVER_CAP) printf '%s' 'eval-gate-over-cap' ;; + EVAL_PLAN_STALE) printf '%s' 'eval-plan-stale' ;; PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;; PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;; AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; diff --git a/bin/gstack-eval-plan b/bin/gstack-eval-plan index 2d5701bf8..accf81c5b 100755 --- a/bin/gstack-eval-plan +++ b/bin/gstack-eval-plan @@ -10,20 +10,29 @@ * gstack-eval-plan pilot settle --out --attempt --usd * gstack-eval-plan pilot release --out --attempt * gstack-eval-plan pilot report --out --total [--workers W] [--json] + * gstack-eval-plan price-gate --gate-file | --gate "" [--plan-file ] [--base ] [--cap-usd N] + * [--smoke-cap-usd N] [--allow-unpriced ""] [--out ] [--json] * * The dry run spends nothing (stub model) and prints the checklist: price-table * entry per arm, output budget sized for reasoning models, the retry prompt's * exact text. The pilot reserves through lib/spend-ledger.ts (estimated admission, * lock-shared refusal), settles actual cost, and `report` prints the spend table * (spent, reserved, unknown charges, worst-case overrun) and the extrapolation - * with its error band before the approval ask. Exit 0 ok, 1 fail (incomplete - * prereg, failed check, exceeded cap), 2 usage, 3 refused. Logic: lib/eval-plan.ts. + * with its error band before the approval ask. `price-gate` prices a paid gate + * from the repo's call-plan DATA file (`--plan-file`, else `price_file` in the + * base branch's .gstack/pregate.json read from the HEAD commit) after checking + * its binding (exact gate command, head, input hashes); it executes nothing and + * writes a receipt (docs/eval-calls.md). Exit 0 ok, 1 fail (incomplete + * prereg, failed check, exceeded cap, unpriced or stale gate), 2 usage, 3 refused. + * Logic: lib/eval-plan.ts. */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { checkPrereg, DEFAULT_TOKENS, dryRun, pilotReport, preregTemplate, releasePilot, renderArms, renderDryRun, renderPilotReport, resolveArms, settlePilot, startPilot } from '../lib/eval-plan'; +import { spawnSync } from 'node:child_process'; +import { checkPrereg, DEFAULT_TOKENS, dryRun, pilotReport, preregTemplate, priceGate, releasePilot, renderArms, renderDryRun, renderGatePrice, renderPilotReport, resolveArms, resolvePlanSource, settlePilot, startPilot } from '../lib/eval-plan'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; +import { resolveBaseBranch } from '../lib/ship-policy'; import { SpendCapExceeded } from '../lib/spend-ledger'; const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, priced pilot (eval-plan/SKILL.md) @@ -32,11 +41,16 @@ const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, pri prereg --init [--title ] write the template arms [--arms m,m] [--policy-doc f] [--input-tokens N] [--output-tokens N] [--json] arms from \`gstack-models resolve --role eval-arms\`, else the repo policy doc, each priced per item - dry-run --input rows.jsonl --arms m,m [--fields a,b] [--output-budget N] [--retry-prompt f] [--max-rows N] [--out dir] [--json] - stub model over the real rows; checklist: price table, output budget, retry prompt text + dry-run --input rows.jsonl --arms m,m [--fields a,b] [--output-budget N] [--retry-prompt f] [--max-rows N] [--cap usd] [--out dir] [--json] + stub model over the real rows; checklist: price table, output budget, retry prompt text, + and with --cap reservation-fits-cap (one call: largest row + output budget, priced) pilot start --out dir --cap usd --arms m,m --dry-run dry-run.json [--n 3] pilot settle --out dir --attempt id --usd pilot release --out dir --attempt id pilot report --out dir --total items [--workers W] [--json] spend table + extrapolation with a 95% band, then the approval ask + price-gate --gate-file | --gate "" [--plan-file f] [--base b] [--cap-usd N] [--smoke-cap-usd N] [--allow-unpriced ""] [--out dir] [--json] + price a paid gate from its GSTACK_EVAL_CALLS v1 call-plan file (--plan-file, else the base + policy's price_file at HEAD); checks the gate/head/input binding, executes nothing, writes a + receipt (default .gstack/tmp/price-gate/); docs/eval-calls.md ${exitTableHelp()}`; @@ -85,13 +99,13 @@ function doDryRun(rest: string[]): number { const arms = csv(flag(rest, '--arms')); if (!input || !fs.existsSync(input)) return usage('dry-run needs --input '); if (!arms.length) return usage('dry-run needs --arms '); - const r = dryRun({ input, arms, fields: csv(flag(rest, '--fields')), outputBudget: num(rest, '--output-budget'), retryPrompt: flag(rest, '--retry-prompt'), maxRows: num(rest, '--max-rows') }); + const r = dryRun({ input, arms, fields: csv(flag(rest, '--fields')), outputBudget: num(rest, '--output-budget'), retryPrompt: flag(rest, '--retry-prompt'), maxRows: num(rest, '--max-rows'), cap: num(rest, '--cap') }); const out = flag(rest, '--out'); if (out) { fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'dry-run.json'), JSON.stringify(r, null, 2) + '\n'); } if (rest.includes('--json')) process.stdout.write(JSON.stringify(r) + '\n'); else process.stdout.write(renderDryRun(r)); if (out) process.stdout.write(`DRY_RUN_RECORD: ${path.join(out, 'dry-run.json')}\n`); if (!r.pass) { - const why = [r.rows === 0 ? 'no rows' : '', r.bad_rows.length ? `${r.bad_rows.length} bad rows` : '', !r.checks.price_table.ok ? `price missing: ${r.checks.price_table.missing.join(',') || 'no arms'}` : '', !r.checks.output_budget.ok ? 'output budget below the reasoning floor' : '', !r.checks.retry_prompt.ok ? 'retry prompt missing' : ''].filter(Boolean).join('; '); + const why = [r.rows === 0 ? 'no rows' : '', r.bad_rows.length ? `${r.bad_rows.length} bad rows` : '', !r.checks.price_table.ok ? `price missing: ${r.checks.price_table.missing.join(',') || 'no arms'}` : '', !r.checks.output_budget.ok ? 'output budget below the reasoning floor' : '', !r.checks.retry_prompt.ok ? 'retry prompt missing' : '', r.checks.reservation_fits_cap && !r.checks.reservation_fits_cap.ok ? `one call exceeds --cap $${r.checks.reservation_fits_cap.cap}` : ''].filter(Boolean).join('; '); process.stderr.write(resultLine('gstack-eval-plan dry-run', r.checks.price_table.ok ? 'EVAL_DRY_RUN_FAILED' : 'EVAL_PRICE_MISSING', why) + '\n'); return EXIT.fail; } @@ -136,6 +150,39 @@ function doPilot(rest: string[]): number { return usage(`unknown pilot command ${sub ?? ''}`); } +const PRICE_GATE_FLAGS = new Set(['--gate', '--gate-file', '--plan-file', '--base', '--cap-usd', '--smoke-cap-usd', '--allow-unpriced', '--out']); + +function doPriceGate(rest: string[]): number { + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--json') continue; + if (!PRICE_GATE_FLAGS.has(rest[i]!) || rest[i + 1] === undefined) return usage(`price-gate: unknown flag or missing value ${rest[i]}`); + i++; + } + const gateFile = flag(rest, '--gate-file'); + if (gateFile && flag(rest, '--gate') !== undefined) return usage('price-gate takes --gate or --gate-file, not both'); + if (gateFile && !fs.existsSync(gateFile)) return usage(`--gate-file ${gateFile} does not exist`); + const gate = gateFile ? fs.readFileSync(gateFile, 'utf8').replace(/\r?\n$/, '') : flag(rest, '--gate'); + if (!gate?.trim()) return usage('price-gate needs the exact gate command: --gate-file (written with your file-write tool) or --gate ""'); + const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }); + if (top.status !== 0) return usage('price-gate runs inside the git repository whose gate it prices'); + const repoRoot = top.stdout.trim(); + const planFile = flag(rest, '--plan-file'); + if (planFile && !fs.existsSync(planFile)) return usage(`--plan-file ${planFile} does not exist`); + const plan = resolvePlanSource(repoRoot, { planFile, base: resolveBaseBranch(repoRoot, flag(rest, '--base')) }); + if ('code' in plan) { process.stderr.write(resultLine('gstack-eval-plan price-gate', plan.code, plan.message) + '\n'); return plan.refused ? EXIT.refused : EXIT.fail; } + const r = priceGate({ repoRoot, gate, plan, capUsd: num(rest, '--cap-usd'), smokeCapUsd: num(rest, '--smoke-cap-usd'), allowUnpriced: flag(rest, '--allow-unpriced') }); + if (r.receipt) { + const out = path.resolve(flag(rest, '--out') ?? path.join(repoRoot, '.gstack', 'tmp', 'price-gate')); + const file = path.join(out, `price-gate-${r.receipt.gate_sha256.slice(0, 12)}-${r.receipt.head_sha.slice(0, 12)}.json`); + fs.mkdirSync(out, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(r.receipt, null, 2) + '\n'); + if (rest.includes('--json')) process.stdout.write(JSON.stringify({ ...r.receipt, receipt_path: file }) + '\n'); + else process.stdout.write([...r.lines, renderGatePrice(r.receipt, file)].join('\n') + '\n'); + } + if (r.error) { process.stderr.write(resultLine('gstack-eval-plan price-gate', r.error.code, r.error.message) + '\n'); return EXIT.fail; } + return EXIT.ok; +} + function main(argv: string[]): number { const [cmd, ...rest] = argv; if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } @@ -144,6 +191,7 @@ function main(argv: string[]): number { if (cmd === 'arms') return doArms(rest); if (cmd === 'dry-run') return doDryRun(rest); if (cmd === 'pilot') return doPilot(rest); + if (cmd === 'price-gate') return doPriceGate(rest); return usage(`unknown command ${cmd}`); } catch (e: any) { if (e instanceof SpendCapExceeded) { process.stderr.write(resultLine(`gstack-eval-plan ${cmd}`, 'EVAL_BUDGET_EXHAUSTED', e.message) + '\n'); return EXIT.fail; } diff --git a/bin/gstack-pregate b/bin/gstack-pregate index b5c1a121c..3f1272690 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -9,6 +9,11 @@ * gstack-pregate clear --remote --run-url | --bundle-lane