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