mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
Merge main 68410828f (v1.93.17.0, train 92) into wave/review-53cfb5bfe011 (rule 19 grant)
Conflicts resolved keeping both sides: bin/gstack-review-plan (baseContainsHead and approval-record imports); scripts/harness-version.json (main's session-ledger and session-runner pins, the wave's skill-fixture pin); ship/DRIVER.md.tmpl (main's done-or-voices wording, regenerated DRIVER.md 12,278 B); test/review-packets-removed.test.ts (merged review-army.md, adversarial.md and review-depth.ts pinned under combined GST-508+GST-464+GST-967 and GST-508+R51+GST-911+GST-967 entries).
This commit is contained in:
commit
f0ce23418c
99 files changed
+3505
-295
No files matched your search
@@ -52,10 +52,16 @@ on:
|
||||
# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`:
|
||||
# each shard runs min(k, its parallel files) sandboxed sub-shards side by side
|
||||
# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child.
|
||||
# FREE_SUITE_SETUP_SECONDS is a free-suite job's time outside the "Run free
|
||||
# suite" step. --ci-plan adds it to each shard's runner wall and flaky-retry
|
||||
# wall to emit that shard's job timeout (#3850), so GitHub never kills a job
|
||||
# before the runner can name a hung file. Measured over 110 free-suite jobs in
|
||||
# PR runs 41467261350..41478988224: p50 60 s, p90 80 s, max 142 s.
|
||||
env:
|
||||
FREE_SUITE_RUNNER: ubicloud-standard-8
|
||||
FREE_SUITE_SHARDS: '10'
|
||||
FREE_SUITE_JOBS: '4'
|
||||
FREE_SUITE_SETUP_SECONDS: '300'
|
||||
|
||||
# Keyed on the PR number, not head_ref: a bare branch name carries no fork
|
||||
# prefix, so same-name branches from two forks would share one group and a
|
||||
@@ -149,7 +155,7 @@ jobs:
|
||||
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then
|
||||
echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs")
|
||||
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs" --job-setup-seconds "$FREE_SUITE_SETUP_SECONDS")
|
||||
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
||||
echo "runner=$runner" >> "$GITHUB_OUTPUT"
|
||||
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
|
||||
@@ -434,10 +440,12 @@ jobs:
|
||||
run: xvfb-run -a bun run test:quick
|
||||
|
||||
free-suite:
|
||||
name: free-suite (${{ matrix.shard }})
|
||||
needs: [free-plan, reuse]
|
||||
if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ${{ needs.free-plan.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
# Per shard, from the plan: runner wall + flaky retry + setup (#3850).
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 20
|
||||
|
||||
+127
@@ -1,5 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.17.0] - 2026-11-02
|
||||
|
||||
- `gstack-ship-queue wave plan` now leaves out a READY head that does not merge onto main. It prints `NOT_MERGEABLE: #<n> (...)` with the conflicting files, the same way `NOT_GREEN` works for red CI. A conflict only in the CHANGELOG doesn't count, since the train merges those. Before, such a head was planned into a wave and the wave build stopped on it.
|
||||
- A PR that renames a file and a PR that edits the old path are now linked into one wave group when the edit conflicts with the rename. Before, the conflict was reported at the new path, which the editing PR never touched, so the two ended up in different waves.
|
||||
- Linked PRs are never split across waves anymore. A group of linked PRs larger than the wave size prints `WAVE_OVERSIZE` with no build command and stays whole. Before, the packer cut such a group at the wave size, so its conflict reached two integrators.
|
||||
- `gstack-ship-queue ready` reports `STAMP_VERSION=unstamped` for a PR whose own changes leave VERSION alone, even after main moves to a newer version. Before, it compared the head with current main, so an unstamped PR showed its old version as a stamp (#3804 showed 1.92.30.0 while main was 1.92.32.0; #3558).
|
||||
|
||||
## [1.93.16.0] - 2026-11-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A test lane's scratch folder no longer breaks the /ship review bind.** When an untracked `.review-units-test-*/` folder appeared or vanished while reviewers ran, Step 11.5 saw a different tree and the only repair was rerunning Steps 9 to 11.5 (about 20M tokens). Review records now fingerprint with `gstack-wtree --review`, which leaves out root dot-folders that hold no tracked file. `.github/` and `.gstack/` always count, and any change to a tracked file still breaks the bind. The pregate, test evidence and every other caller keep the whole tree. (GST-985)
|
||||
|
||||
## [1.93.15.0] - 2026-11-02
|
||||
|
||||
**Pregate stops blaming a clean branch for a missing pytest, Playwright specs and mixed-language root tests, and the literals check stops flagging stray digits (#3598, #3492, gstack-evals#97).** A machine without pytest reported "fix the in-branch failure". Playwright specs in a vitest repo went to vitest, which found no tests and failed. Root tests in `.mjs` and `.py` got no runner at all. And any lone `2` in a file that named a changed constant (a comment, a `$2` in a shell stub, a fixture) failed `literals`. A wrapper bump from 3 to 4 would have raised 5 false hits here, and gstack-evals#97 hit 11. Rewritten from outside PRs #3639 and #3600 by @vilano93 and #3629 by @nfitzsimmons.
|
||||
|
||||
### Fixed
|
||||
- **Missing pytest is the machine's gap.** Before planning `python3 -m pytest`, `lanes` asks that python3 whether it can import pytest (10 s probe, from the repo root). If it can't, the file is unclaimed and the lane is `PREGATE_ENVIRONMENT` (missing-binary, `pytest` or `python3`): "install pytest on this machine and rerun, or run the lane remotely; the diff is not at fault".
|
||||
- **Playwright specs run under Playwright.** A JS/TS test file that imports `@playwright/test` at the start of a line runs as `<pm exec> playwright test --reporter=list` from its nearest package. Other tests keep their runner. A declared `runner` still takes every file. Playwright's summary is read count line by count line. Failed and interrupted count as failed, flaky and passed as passed, and skipped as not run. A run where every test fails and Playwright says its browser is not installed is `PREGATE_ENVIRONMENT` naming `npx playwright install`.
|
||||
- **Mixed-language root tests each get a runner.** A repo's bun, vitest or jest now claims only JS/TS files. When no one runner fits every root test, they are split by language and each part is detected on its own. So `.mjs` with `.py` runs `node --test` and pytest, and a `.py` test in a vitest repo gets pytest instead of vitest. `gstack-pregate init` drops its "a selection mixing these languages needs runner set" note.
|
||||
- **`literals` binds a one-character old value to its statement.** The value counts on a line that names the constant, or on up to three lines that continue that statement, never on a comment line. Version-history rows count as before. A lowercase call-site name (`attempts`, `limit`) also needs the changed module named in the file. Values of two or more characters are unchanged.
|
||||
|
||||
### Changed
|
||||
- **docs/pregate.md** describes the new detection and the literals binding. **docs/troubleshooting.md** names the Playwright missing-browsers case under `PREGATE_ENVIRONMENT`. **docs/state-root.md** lists `pregate/runs/<run id>/`.
|
||||
|
||||
## [1.93.14.0] - 2026-11-02
|
||||
|
||||
Free Tests no longer goes red with no named file when one test hangs. Each `free-suite` job's GitHub timeout used to be a fixed 20 minutes, shorter than the runner's own wall for a 10-shard plan (about 37 minutes), so GitHub killed the job before the runner could name the hung file or retry it, and no shard result was uploaded. The plan now emits each shard's job timeout as its runner wall plus the flaky-retry pass plus the job's measured setup (`--ci-plan --job-setup-seconds`), 49 minutes per shard today, and fails loudly above 90 minutes instead of truncating. See docs/TESTING_INTERNALS.md#runners-how-the-suites-execute-2026-08-overhaul. Closes #3850.
|
||||
|
||||
## [1.93.13.0] - 2026-11-02
|
||||
|
||||
**gstack's own tests no longer write into a developer's real gbrain.** If you export `GBRAIN_DATABASE_URL` for your working brain and run gstack's test suite, tests that spawn bins with a temp `HOME` used to pass that URL straight through. With no gbrain config under the temp `HOME`, the real `gbrain` on your PATH then added `brain-sync-remote-*` sources and test pages to your live brain. From #3466 by @AndreIntelas.
|
||||
|
||||
### Fixed
|
||||
- **The test preload (`test-setup.ts`) strips the variables gbrain uses to pick a brain** before any test module loads: `GBRAIN_DATABASE_URL`, `GBRAIN_DIRECT_DATABASE_URL`, `DATABASE_URL` and `GBRAIN_HOME`. It prints one line on stderr for each variable it strips. This covers `bun test <file>` and every shard of `bun run test`.
|
||||
|
||||
### Tests
|
||||
- `test/state-root-config-merge.test.ts`: a `bun test` run with all four variables exported spawns a child under a temp `HOME` that calls `buildGbrainEnv`, and the child sees none of them. It fails on main.
|
||||
|
||||
## [1.93.12.0] - 2026-11-02
|
||||
|
||||
**The Opus 5.5 `lite` skills now keep the install's gbrain blocks, disabled skills and the Claims Need Evidence rule (GST-944).** The lite tree Capy publishes was rendered without the install's settings. On a machine with gbrain detected, Opus 5.5 sessions lost the brain context-load and save-results blocks that the full install serves, and skills turned off with `disabled_skills` showed up again in the lite router. `./setup --loadout lite` was not affected.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-modules install`** renders lite with the state root's gbrain detection and `gstack-config` `disabled_skills`, and both are part of the folder's identity. A change in either publishes a new folder instead of reusing a stale one.
|
||||
|
||||
### Changed
|
||||
- **`preamble:evidence` is pinned**, so `lite` drops ten coaching pieces instead of eleven. The honest-work design (W4) never retires the Claims Need Evidence rule while gstack reports evidence. It adds 804 bytes back to lite's shared preamble (16,175 → 16,979 bytes; full is 22,396).
|
||||
|
||||
### Tests
|
||||
- `test/module-loadouts-install.test.ts`: with gbrain detected and `disabled_skills: qa`, the lite office-hours file carries the brain blocks, the router lists `/qa` as disabled, and the folder differs from the one published without those settings.
|
||||
- `test/module-loadouts.test.ts`: `preamble:evidence` is pinned and lite excludes the other ten pieces.
|
||||
|
||||
## [1.93.11.0] - 2026-11-02
|
||||
|
||||
**Capy sessions get their model's loadout again without a sentence in the skill (GST-982).** The browse wave (#3858) removed the Capy skill's instruction to run the installer with `GSTACK_MODEL`, to keep `capy/skill/gstack/SKILL.md` under its context budget. After that, every Capy session installed the full skills, including Opus 5.5 sessions that should get `lite`. Now the installer finds the model by itself.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-capy-install` reads the owner's model preference.** When it gets no `--model` and no `GSTACK_MODEL`, it uses the `agent_model` in the session's `GSTACK_PREFERENCES_FILE` (the Drive skill's `preferences.yaml`, which `gstack-capy-session` already exports), resolved by `gstack-models resolve --role agent`. So `opus` means `claude-opus-5-5` today. The line reads `LOADOUT: … source=preferences` (`source=config` when this machine's `config.yaml` sets it), followed by a `LOADOUT_ASSUMED:` note that the model came from the preference and not the session. Eval runs (`GSTACK_EVAL_DIR` or `EVALS_RUN_ID` set) skip the preference, and a preference `gstack-models` rejects prints `LOADOUT_FALLBACK` instead of serving full silently. `GSTACK_MODEL` and `--model` still win, and a `host` preference or no file still serves full. The frozen bootstrapper `capy/skill/gstack/scripts/install.sh` and the skill text are unchanged.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-capy-install.test.ts`: an `opus` preference picks lite with `source=preferences` and its own launch file, `GSTACK_MODEL` overrides the preference, a `config.yaml` preference reads `source=config`, eval runs skip the preference, a rejected preference prints `LOADOUT_FALLBACK`, a `host` preference prints no loadout lines, and the real `gstack-models resolve --role agent` keeps the `agent: <provider>/<model>` first line the installer reads.
|
||||
|
||||
## [1.93.10.0] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Evals test frontier models (R54). /eval-plan and docs/TESTING_INTERNALS.md#frontier-models-under-test now say that models under test are Opus 5.5 and GPT-6.1 Sol, plus at most one named low-cost primary. Haiku and Sonnet aren't stand-ins for a frontier model, and a Haiku- or Sonnet-only result can't carry a KEEP, FIX-METHOD or CUT-CANDIDATE verdict. Judges, review voices and housekeeping models keep their models. `gstack-eval-plan dry-run` refuses a plan whose every arm is Haiku or Sonnet unless `--tier-question` names what it asks about that tier, warns on a mixed plan, and prices the whole plan on frontier models. `pilot start` refuses a Haiku- or Sonnet-only pilot unless its dry run recorded that tier question.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Agent SDK evals can no longer run on the SDK's bare stub prompt by accident. Every `runAgentSdkTest` and direct SDK `query()` caller now passes Claude Code's preset or names why it doesn't. The runner refuses a missing, empty or bare-string prompt, and a free tripwire test scans test/ and scripts/ for the same mistake. docs/TESTING_INTERNALS.md#agent-sdk-system-prompt says to record which prompt each arm used, and to treat a null result from an arm on the stub as FIX-METHOD.
|
||||
|
||||
**A test that keeps flaking while someone fixes it can be quarantined instead of skipped, and it still can't turn a red run green.** `gstack-flaky quarantine add` puts the test in `.gstack/quarantine.json` with an owner, a ledger link and an expiry. The test keeps running on every run and is never counted as a pass. When every test an attempt failed is quarantined, the whole command may run again, up to 3 attempts, and the run is green only when an attempt gets the runner's own full green verdict, so a coverage threshold or another failure outside the report stays red. Before this, a test that flaked every few runs either went red on every PR or got skipped and forgotten.
|
||||
|
||||
### Added
|
||||
- **Quarantine registry, `gstack-flaky quarantine add|renew|release|check|report`** (`.gstack/quarantine.json`, GSTA-158 P2): a quarantined test that fails all 3 attempts is a `hard-break` (red), a quarantined id missing from a run that included its file is red, and quarantined tests are listed (`FLAKY_RESULT … quarantined=<n>`) and ledgered (`quarantined-pass`, `quarantined-fail`, `hard-break`) on every run. Tests that are not quarantined keep their one rerun.
|
||||
- **Entries are owned and expire**: owner, ledger link, class (`detector`, `harness`, `timing`, `model-latency`; `product` and `infra` refused), evidence, 7 days by default and 14 at most, one renewal of up to 7 days with new evidence and the owner named again. An expired entry stops applying with no edit and prints the renew and release commands. Live entries are capped at 10% of the suite (at least 1, and 5 under 50 tests).
|
||||
- **`run --base <ref>`** reads the registry from the base branch, so a change cannot quarantine the test it breaks. **`check --base`** refuses an entry added in the change that edits its test's file, an entry removed without a release record, a removed release record and rewritten history. **`report --ledger <file|dir>`** lists entries soonest-expiring first and marks those with 10 consecutive clean passes ready to release. gstack's own `CASE_QUARANTINE` / `EVAL_POLICY` is unchanged and shares no schema with it.
|
||||
|
||||
### Docs
|
||||
- `docs/flaky.md#quarantine`: commands, rules, expiry, cap, classes, the `CASE_QUARANTINE` separation and the daily-pass `gh run download` + `report` loop. `captain/templates/automation-flaky-pass.md` and `docs/captain.md` run `report` and fix expiring entries first.
|
||||
|
||||
### Tests
|
||||
- `test/quarantine.test.ts`: add refusals (cap, more than 14 days, no evidence, product or infra class, unknown id), one renewal and future-dated renewals, release, `check --base` against git fixtures (non-ASCII paths included), expiry, a hard break across 3 attempts, the false-green regression (exit 1 from outside the report while only a quarantined test fails stays red), missing ids with their file present or absent, and the registry read from the base branch.
|
||||
|
||||
### Gate integrity
|
||||
- `test/flaky-run.test.ts`: the exact `FLAKY_RESULT` line it expects gains the new `quarantined=0` field. No other assertion, threshold or skip changed.
|
||||
|
||||
- `docs/support-tiers.md` describes Windows CI as it works now. `windows-smoke` is the required Windows check on pull requests, and the six-shard `windows-free-tests` suite runs nightly on main or by hand and never blocks a pull request. The page was written before #3806 landed, so it still said these changes were coming.
|
||||
|
||||
**The train gate's paid report now prices the trials it used to call "cost unknown".** 32 of 139 gate trials had no cost on every gate since Oct 26, so the report's total understated a gate by about $7.50 (GSTA-150 audit). All four causes are fixed.
|
||||
|
||||
### Fixed
|
||||
- **Label-named records in multi-case shards** (`test/helpers/e2e-helpers.ts`, `scripts/lib/paid-report.ts`): a `claude -p` record named by its display label ("browse basic commands") carries `cost_case_id` from `runSkillTest`'s registered test name, and the report binds its cost to that case. The failed record, failure class and model still come from the records named by case id, so no verdict, class or pass-rate series changes (22 trials: bws, session-intelligence, workflow, skillify, review-attribution, review, gate-integrity, diagram).
|
||||
- **Aborted Agent SDK sessions** (`test/helpers/agent-sdk-runner.ts`, `lib/pricing.ts`): a session that ends without a result event logs its streamed usage as `streamed_cost_usd` in the session ledger, priced once per message id at its largest output, subagent turns included, with 5-minute cache writes at 1.25x and 1-hour writes at 2x input. An unpriced model leaves the cost unknown (auq-format-gate).
|
||||
- **Model-free cases**: `timeline-event-flow` runs no model, so with no record and no session it is a known $0. A test checks every listed case's body starts no model session.
|
||||
- **PTY sessions** (`test/helpers/pty/launch.ts`, `test/helpers/pty/session.ts`, `test/helpers/pty/transcript-cost.ts`): each PTY ledger row prices the session's transcript (`projects/*/<id>.jsonl` plus its `subagents/` files) as `streamed_cost_usd`. The session is the caller's `--session-id`, else the one transcript that appeared during the launch for its cwd, so the launcher adds no argument and concurrent tests sharing a config dir never price each other. No transcript, two new transcripts for one cwd, no usage or an unpriced model leaves the cost unknown, never $0, and a read error never reaches the test. The native AskUserQuestion capture, which never reads model transcripts, opts out (`priceTranscript: false`). This covers 8 cases and 14 sessions per gate: plan-mode-no-op, plan-design-with-ui-scope, the plan-ceo and plan-devex finding-floor and plan-mode cases, autoplan-guard-pty and office-hours-auto-mode. Checked live, a `claude -p` session with one subagent priced $0.090376 from its transcripts, equal to its `total_cost_usd`.
|
||||
- **The headline total** adds session cost that no collector record carries, so newly priced trials reach the gate's `cost $X` line exactly once.
|
||||
|
||||
### Tests
|
||||
- `test/paid-report-cost.test.ts`: pricing arithmetic, streamed dedupe, `caseCost`, `recordE2E`'s `cost_case_id`, a two-case shard whose label-named records bind by cost without changing the failed record, class or model. `test/paid-report-census.test.ts`: a record-less trial's streamed cost reaches the trial and the verdict total once. `test/paid-report-cost.test.ts` also prices a PTY session, its inline sidechains and subagent files once per message id, leaves a foreign session, a missing or unreadable transcript or an unpriced model unknown, and claims only the one new transcript for a launch's cwd. `test/helpers/claude-pty-runner.launch.unit.test.ts`: a real launch prices the transcript it created without adding an argument, and an opted-out launch prices nothing.
|
||||
|
||||
## [1.93.9.0] - 2026-11-02
|
||||
|
||||
**`gstack-ship-receipt read --check-prose` no longer reads the required Review-loop exception line as counter claims (GST-977).** `write --review-loop-exception` requires the PR body to carry the whole `Review-loop exception: …` line word for word, but the prose check read that line as prose. On gstack-evals #139 the owner's reason "outside 1 and 2 skip detector" was read as a `2 skip` claim against the receipt's 70 skips, so no body could pass both checks.
|
||||
|
||||
### Fixed
|
||||
- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own exception exactly (the same test `write` applies) is skipped, like the rendered `## Approvals` block. A copy of that exact line inside a code fence is skipped too, as `write` accepts one. The same words inside a longer line, or a line for a different exception, are still checked.
|
||||
|
||||
### Tests
|
||||
- `test/ship-receipt-prose.test.ts`: the #139 exception text passes `--check-prose` beside a matching `70 skip` claim, and a wrong number elsewhere still fails. On main the same body failed with `gate.unit.skip body=2 receipt=70`.
|
||||
|
||||
## [1.93.8.0] - 2026-11-02
|
||||
|
||||
**/ship now runs Step 11's voices before any gate-class stop, and a recorded owner grant runs its fix-check.** On a gate-class change, a blocker fixed in review round 2 used to stop the run for the owner before Step 11 ran. If the owner then chose "ship with open notes", gate code shipped with no adversarial, outside or targeted review (C6-36 after-opus). `gstack-review-plan` now answers `NEXT: voices` for any gate-class stop while Step 11 has not run. /ship runs Step 11 as it does on `done`, asks the plan again with `--adversarial-ran`, and the stop then stands. The round cap still limits fix rounds; it no longer skips the required voices. Second, the owner's grant of one scoped fix-check after round 2 had no way in: the plan took no grant input, and the pass program printed `PASS: none next=stop`. Now `gstack-review-plan --grant <approval id>` checks that the branch's `gstack-approvals` record holds that id as an approved grant naming `review_loop_grant` and this run's stop head (exit 3 for any other row, a grant recorded for an earlier /ship on the branch included), and plans the one fix-check in place of the stop after a round-2 full pass. The pass program then dispatches that fix-check like any other. A fix-check never earns another, so the grant cannot repeat. After `NEXT: voices`, a blocking finding the voices queue goes back to the plan as `--blocking-open` instead of starting a new review pass.
|
||||
|
||||
### Tests
|
||||
- `test/review-depth.test.ts`: a gate-class round-2 blocker fix plans `voices` (adversarial, outside and targeted) before every stop and stops once they ran. A grant plans one fix-check after round 2, after the voices on gate class, and never a second. The transitions table and rule 3 in `docs/review-policy.md` say both. All four failed at main 1022f4f.
|
||||
- `test/review-plan-cli.test.ts`: `--grant` with a recorded grant prints `NEXT: fix-check`; an unrelated approval, an unrecorded id, a `review_loop_exception` acceptance row or a grant for an earlier head exits 3; after a fix-check the grant never plans another. Failed at main 1022f4f (`NEXT: stop`).
|
||||
- `test/review-depth.test.ts`: an invariant over every transitions-table state (gate class or not, Step 11 run or not, grant or not) and a dispatch-superset no-loss check against main's plans.
|
||||
- `test/review-pass.test.ts`: the pass program runs the granted fix-check reader instead of `PASS: none next=stop`. Failed at main 1022f4f.
|
||||
|
||||
### Docs
|
||||
- `docs/review-policy.md`: rule 3 and two new rows in the transitions table. `ship/DRIVER.md`, `ship/sections/review-army.md` and `ship/sections/adversarial.md` route `voices` and `--grant`.
|
||||
|
||||
## [1.93.7.0] - 2026-11-02
|
||||
|
||||
**A version stamp the branch added at its fork point's version now counts as stale after the base moves (GST-963).** On gbrain #8425, a branch added a row that named the fork point's version. After main moved past it, `gstack-restamp` skipped the row ("no tentative mention") and `gstack-ship-release finish` routed the line as a behavior change, so the stamp kept the old version and the docs stage flagged a product edit.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.7.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.17.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,
|
||||
|
||||
+18
-4
@@ -42,7 +42,8 @@
|
||||
# mode-0600 file beside the verdict; callers and their children `. <file>` it,
|
||||
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
|
||||
# grep that, never pipe through tail).
|
||||
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), names the model this session runs:
|
||||
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), else the agent_model of the session's
|
||||
# GSTACK_PREFERENCES_FILE as gstack-models resolve --role agent reads it (GST-982), names the model this session runs:
|
||||
# bin/gstack-modules install picks its loadout, publishes a non-full one beside
|
||||
# the full Claude install and prints LOADOUT and SKILL_ROOT lines before the
|
||||
# launch lines; a non-full loadout gets its own launch file, keyed by loadout
|
||||
@@ -723,16 +724,29 @@ fi
|
||||
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
|
||||
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
|
||||
LOADOUT_LINES=""
|
||||
if [ "$RC" -eq 0 ] && [ -z "$MODEL" ] && [ -n "${GSTACK_PREFERENCES_FILE:-}" ] && [ -z "${GSTACK_EVAL_DIR:-}${EVALS_RUN_ID:-}" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
|
||||
_ag_out=$(cd "$ROOT" && bin/gstack-models resolve --role agent 2>&1) && _ag_rc=0 || _ag_rc=$?
|
||||
_ag_first=$(printf '%s\n' "$_ag_out" | head -1)
|
||||
case "$_ag_first" in
|
||||
*", this machine") MODEL_SOURCE=config ;;
|
||||
*", the preferences file") MODEL_SOURCE=preferences ;;
|
||||
esac
|
||||
[ "$_ag_rc" -eq 0 ] || LOADOUT_LINES="LOADOUT_FALLBACK: agent_model unreadable: $(printf '%s' "$_ag_first" | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
|
||||
[ "$MODEL_SOURCE" = GSTACK_MODEL ] || MODEL=$(printf '%s' "$_ag_first" | sed -n 's|^agent: [a-z0-9-]*/\([A-Za-z0-9._-]*\) .*|\1|p')
|
||||
fi
|
||||
if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
|
||||
_lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)")
|
||||
if [ "$MODEL_SOURCE" = model-flag ]; then
|
||||
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
|
||||
else
|
||||
if [ "$MODEL_SOURCE" = GSTACK_MODEL ]; then
|
||||
_lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1)
|
||||
else
|
||||
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
|
||||
fi
|
||||
_lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ')
|
||||
case "$MODEL_SOURCE" in preferences|config) _lo_line=$(printf '%s' "$_lo_line" | sed "s/ source=model-flag/ source=$MODEL_SOURCE/") ;; esac
|
||||
_lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1)
|
||||
_lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ')
|
||||
case "$MODEL_SOURCE" in preferences|config) _lo_notes="${_lo_notes:+$_lo_notes
|
||||
}LOADOUT_ASSUMED: model=$MODEL is the owner's agent_model ($MODEL_SOURCE), not read from the session; a session on another model sets GSTACK_MODEL=<its id>" ;; esac
|
||||
_lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p')
|
||||
_lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p')
|
||||
if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* gstack-eval-plan prereg --check <file> [--allow-repo-commands] [--json] | --init <file> [--title <t>]
|
||||
* gstack-eval-plan arms [--arms <model,model>] [--policy-doc <file>] [--input-tokens N] [--output-tokens N] [--json]
|
||||
* gstack-eval-plan dry-run --input <rows.jsonl> --arms <model,model> [--fields a,b] [--output-budget N]
|
||||
* [--retry-prompt <file>] [--max-rows N] [--out <dir>] [--json]
|
||||
* [--retry-prompt <file>] [--max-rows N] [--tier-question <why>] [--out <dir>] [--json]
|
||||
* gstack-eval-plan pilot start --out <dir> --cap <usd> --arms <model[#loadout],...> --dry-run <dry-run.json> [--n 3] [--seat-metered <arm,...>]
|
||||
* gstack-eval-plan pilot settle --out <dir> --attempt <id> --usd <n|unknown>
|
||||
* gstack-eval-plan pilot release --out <dir> --attempt <id>
|
||||
@@ -50,9 +50,11 @@ const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, pri
|
||||
prereg --init <file> [--title <t>] 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] [--cap usd] [--out dir] [--json]
|
||||
dry-run --input rows.jsonl --arms m,m [--fields a,b] [--output-budget N] [--retry-prompt f] [--max-rows N] [--cap usd] [--tier-question "<why>"] [--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)
|
||||
and with --cap reservation-fits-cap (one call: largest row + output budget, priced);
|
||||
frontier: refuses a plan whose every arm is Haiku or Sonnet unless --tier-question
|
||||
names what it asks about that tier, and re-costs the plan on frontier prices (R54)
|
||||
pilot start --out dir --cap usd --arms m,m#no-gstack --dry-run dry-run.json [--n 3] [--seat-metered arm,...]
|
||||
--seat-metered: arms on a subscription with no incremental charge; they reserve $0,
|
||||
settle unknown, and sit outside the cap and the projection until one settles a charge
|
||||
@@ -165,13 +167,15 @@ function doDryRun(rest: string[]): number {
|
||||
if (!arms.length) return usage('dry-run needs --arms <model,model>');
|
||||
const scope = scopeOf(rest);
|
||||
const modelRules = lintModels(rest, arms.map(model => ({ model, scope, where: 'dry-run --arms' })), 'dry-run');
|
||||
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'), modelRules });
|
||||
const tierQuestion = flag(rest, '--tier-question');
|
||||
if (tierQuestion?.startsWith('--')) return usage('--tier-question needs the question the preregistration asks about that tier, not another flag');
|
||||
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'), modelRules, tierQuestion });
|
||||
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' : '', r.checks.reservation_fits_cap && !r.checks.reservation_fits_cap.ok ? `one call exceeds --cap $${r.checks.reservation_fits_cap.cap}` : '', r.checks.model_rules && !r.checks.model_rules.ok ? 'model rules refused an arm (EVAL_MODEL lines above)' : ''].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}` : '', r.checks.model_rules && !r.checks.model_rules.ok ? 'model rules refused an arm (EVAL_MODEL lines above)' : '', !r.checks.frontier.ok ? 'every arm is Haiku or Sonnet (R54; CHECK: frontier above)' : ''].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;
|
||||
}
|
||||
|
||||
+40
-4
@@ -9,11 +9,13 @@
|
||||
import * as fs from 'fs';
|
||||
import { DEFAULT_MAX_RERUNS, appendLedger, ledgerPath, renderResult, runFlaky, safeText, stepSummary, workflowEscape } from '../lib/flaky-run';
|
||||
import { JunitError, readJunit, type JunitRunner } from '../lib/junit';
|
||||
import { liveQuarantine, QuarantineError, quarantineMain, readRegistry } from '../lib/quarantine';
|
||||
|
||||
const USAGE = `Usage:
|
||||
gstack-flaky run --junit <file.xml> [--runner playwright|bun|generic] [--max-reruns <n>]
|
||||
[--rerun-scope command|test] [--ledger <file>] -- <test command...>
|
||||
[--rerun-scope command|test] [--ledger <file>] [--base <ref>] -- <test command...>
|
||||
gstack-flaky ids --junit <file> [--runner playwright|bun|generic]
|
||||
gstack-flaky quarantine add|renew|release|check|report ... (gstack-flaky quarantine --help)
|
||||
gstack-flaky --help
|
||||
|
||||
run: runs the command (an argv, no shell), reads the JUnit report it writes at
|
||||
@@ -31,11 +33,18 @@ holds the first run's report afterwards; reruns' reports sit beside it as
|
||||
<name>.rerun-<n>.xml. The command runs with PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1
|
||||
so Playwright's own retries are reported.
|
||||
|
||||
Quarantine: tests listed in .gstack/quarantine.json (read from --base <ref> when
|
||||
given, so PR CI passes its target branch) still run and never count as passes.
|
||||
When every test an attempt failed is a live quarantined test, the whole command
|
||||
may run again, up to 3 attempts; the run is green only on an attempt with the
|
||||
runner's full green verdict, and a quarantined test that fails all 3 is a hard
|
||||
break (red). A quarantined id missing from a run that included its file is red.
|
||||
|
||||
ids: prints the id of every test in a report (<file>::<project>::<title>).
|
||||
|
||||
Output: one FLAKY: line per test that failed at least once, FLAKY_RERUN:
|
||||
scope=command (<why>) when the rerun repeated the whole command, then
|
||||
FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>
|
||||
FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> quarantined=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>
|
||||
Exit: 0 pass (flaky passes included), 1 a test failed, 2 usage,
|
||||
3 no usable report, a crash or a killed runner (FLAKY_<CODE> and a fix: line on stderr).
|
||||
`;
|
||||
@@ -48,6 +57,10 @@ const FIX: Record<string, string> = {
|
||||
FLAKY_COMMAND_KILLED: 'the runner was killed by a signal (a cancelled job, an OOM kill); nothing was rerun, rerun the job',
|
||||
FLAKY_COMMAND_NOT_STARTED: 'check the command after -- (it runs without a shell; wrap it as bash -c \'<script> "$@"\' _ when it needs one)',
|
||||
FLAKY_UNREPORTED_FAILURES: 'the runner counted failures its JUnit omits (a test file that failed to load, an error outside any test); fix that first, it is a real red',
|
||||
FLAKY_QUARANTINE_INVALID: 'fix .gstack/quarantine.json: it must be {"schema":1,"entries":{...},"released":[...]} (gstack-flaky quarantine check names the problem)',
|
||||
FLAKY_QUARANTINE_BASE: 'the base branch is not in this clone: fetch it before the run (in Actions, actions/checkout with fetch-depth: 0) and pass it as --base origin/<branch>',
|
||||
FLAKY_QUARANTINE_REFUSED: 'read the reason above; docs/flaky.md#quarantine lists the rules',
|
||||
FLAKY_QUARANTINE_USAGE: 'gstack-flaky quarantine --help',
|
||||
};
|
||||
|
||||
function die(msg: string, code = 2): never {
|
||||
@@ -60,8 +73,13 @@ function coded(e: JunitError): never {
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
function quarantineFailed(e: QuarantineError): never {
|
||||
process.stderr.write(`gstack-flaky: ${e.message} (${e.code})\nfix: ${FIX[e.code] ?? 'see docs/flaky.md#quarantine'}\n`);
|
||||
process.exit(e.exitCode);
|
||||
}
|
||||
|
||||
function parse(argv: string[]) {
|
||||
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; rerunScope: 'command' | 'test'; ledger?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, rerunScope: 'command', command: [] };
|
||||
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; rerunScope: 'command' | 'test'; ledger?: string; base?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, rerunScope: 'command', command: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
const val = () => { const v = argv[++i]; if (v === undefined) die(`${a} needs a value`); return v; };
|
||||
@@ -80,6 +98,7 @@ function parse(argv: string[]) {
|
||||
if (v !== 'command' && v !== 'test') die(`--rerun-scope must be command or test, got ${JSON.stringify(v)}`);
|
||||
o.rerunScope = v;
|
||||
} else if (a === '--ledger') o.ledger = val();
|
||||
else if (a === '--base') o.base = val();
|
||||
else die(`unknown option ${a}\n${USAGE}`);
|
||||
}
|
||||
return o;
|
||||
@@ -88,6 +107,16 @@ function parse(argv: string[]) {
|
||||
const [sub, ...rest] = process.argv.slice(2);
|
||||
if (!sub || sub === '--help' || sub === '-h') { process.stdout.write(USAGE); process.exit(sub ? 0 : 2); }
|
||||
|
||||
if (sub === 'quarantine') {
|
||||
try {
|
||||
process.exit(quarantineMain(rest, { cwd: process.cwd(), out: s => process.stdout.write(s), now: new Date() }));
|
||||
} catch (e) {
|
||||
if (e instanceof QuarantineError) quarantineFailed(e);
|
||||
if (e instanceof JunitError) coded(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === 'ids') {
|
||||
const o = parse(rest);
|
||||
if (!o.junit) die('ids needs --junit <file>');
|
||||
@@ -107,9 +136,15 @@ try { existing = fs.statSync(o.junit, { throwIfNoEntry: false }) && fs.readFileS
|
||||
}
|
||||
if (existing && existing.trim() && !/<testsuites?[\s>/]/.test(existing)) die(`--junit ${JSON.stringify(o.junit)} exists and is not a JUnit report; gstack-flaky deletes that path before every attempt, so it refuses to touch it`);
|
||||
if (!o.command.length) die('run needs the test command after --');
|
||||
let quarantined: Set<string>;
|
||||
try {
|
||||
const live = liveQuarantine(readRegistry(process.cwd(), o.base), new Date());
|
||||
for (const n of live.notices) process.stdout.write(`${n}\n`);
|
||||
quarantined = live.ids;
|
||||
} catch (e) { if (e instanceof QuarantineError) quarantineFailed(e); throw e; }
|
||||
let result;
|
||||
try {
|
||||
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunScope: o.rerunScope });
|
||||
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunScope: o.rerunScope, quarantined });
|
||||
} catch (e) { if (e instanceof JunitError) coded(e); throw e; }
|
||||
const ledger = ledgerPath(o.ledger);
|
||||
appendLedger(result.ledger, ledger, s => process.stderr.write(`gstack-flaky: ${s}\n`));
|
||||
@@ -118,6 +153,7 @@ const summary = process.env.GITHUB_STEP_SUMMARY && stepSummary(result);
|
||||
if (summary) { try { fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY!, summary); } catch { /* a summary is a convenience, never the verdict */ } }
|
||||
if (process.env.GITHUB_ACTIONS === 'true') {
|
||||
for (const f of result.outcomes.filter(x => x.status === 'flaky-pass')) process.stdout.write(`::warning title=flaky test::${workflowEscape(safeText(f.test.id))} passed on attempt ${f.attempts.length}\n`);
|
||||
for (const f of result.outcomes.filter(x => x.status === 'hard-break')) process.stdout.write(`::error title=quarantined test hard break::${workflowEscape(safeText(f.test.id))} failed all ${f.attempts.length} attempts\n`);
|
||||
}
|
||||
if (result.code) {
|
||||
process.stderr.write(`gstack-flaky: ${result.message} (${result.code})\nfix: ${FIX[result.code] ?? 'see docs/flaky.md'}\n`);
|
||||
|
||||
@@ -69,7 +69,7 @@ if [ -n "$COMMIT_FULL" ]; then
|
||||
esac
|
||||
trap 'rm -rf "$WTREE_OBJECTS"' EXIT
|
||||
trap 'exit 130' INT TERM
|
||||
WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" || true)
|
||||
WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" --review || true)
|
||||
export GIT_ALTERNATE_OBJECT_DIRECTORIES="$WTREE_OBJECTS${GIT_ALTERNATE_OBJECT_DIRECTORIES:+:$GIT_ALTERNATE_OBJECT_DIRECTORIES}"
|
||||
else
|
||||
echo "gstack-review-log: cannot create a private object directory under ${TMPDIR:-/tmp}; no working-tree fingerprint. Set TMPDIR to a writable directory." >&2
|
||||
|
||||
+20
-3
@@ -5,7 +5,7 @@
|
||||
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
|
||||
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
|
||||
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
|
||||
* [--targeted-ran <targeted_patch_id>] [--json]
|
||||
* [--targeted-ran <targeted_patch_id>] [--grant <approval id>] [--json]
|
||||
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
|
||||
*
|
||||
* Reads the diff against the merge base with <ref> (committed, tracked uncommitted
|
||||
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { baseContainsHead } from '../lib/default-base';
|
||||
import { locate, readRecord } from '../lib/approval-record';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
@@ -61,12 +62,17 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
|
||||
--pass1-depth <d> pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full
|
||||
--targeted-ran <id> the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted
|
||||
reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again
|
||||
--grant <approval id> the owner's grant of one scoped fix-check after round 2, recorded with gstack-approvals add (approved,
|
||||
its ask or scope naming review_loop_grant and the stop head's sha, at or after --range-base and at or
|
||||
before HEAD; needs --range-base): the stop after a round-2 full pass with a fixed blocking finding or a
|
||||
gate-class edit plans that fix-check instead; a fix-check never earns another; any other row is refused (exit 3)
|
||||
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
|
||||
|
||||
Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR;
|
||||
standard only by --depth-floor or a raise; otherwise light. TARGETED: the money,
|
||||
gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped
|
||||
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan
|
||||
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, voices (gate class: Step 11 before any stop,
|
||||
then ask again with --adversarial-ran), stop or done. A full plan
|
||||
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
|
||||
${exitTableHelp()}`;
|
||||
|
||||
@@ -99,11 +105,22 @@ function main(): number {
|
||||
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
|
||||
const floor = flag('--depth-floor') as Depth | undefined;
|
||||
if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; }
|
||||
const grant = flag('--grant');
|
||||
if (has('--grant') && (!grant || grant.startsWith('-'))) { process.stderr.write('gstack-review-plan: --grant needs the approval id gstack-approvals add printed\n'); return EXIT.usage; }
|
||||
const rangeBase = flag('--range-base');
|
||||
if (has('--range-base') && (!rangeBase || rangeBase.startsWith('-'))) { process.stderr.write('gstack-review-plan: --range-base needs the fix range\'s base commit (this pass\'s PASS_HEAD)\n'); return EXIT.usage; }
|
||||
if (grant && !rangeBase) { process.stderr.write('gstack-review-plan: --grant needs --range-base (round 2\'s PASS_HEAD), which binds the grant to the stop it answers\n'); return EXIT.usage; }
|
||||
const root = git(['rev-parse', '--show-toplevel'])?.trim();
|
||||
if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; }
|
||||
process.chdir(root);
|
||||
if (grant) {
|
||||
const loc = locate({ cwd: root, env: process.env });
|
||||
const row = ('file' in loc ? readRecord(loc).rows : []).find(r => r.id === grant && r.outcome === 'approved' && `${r.ask} ${r.scope}`.includes('review_loop_grant'));
|
||||
const passHead = git(['rev-parse', '--verify', '--quiet', `${rangeBase}^{commit}`])?.trim();
|
||||
const ancestor = (a: string, d: string) => git(['merge-base', '--is-ancestor', a, d]) !== null;
|
||||
const stopHead = row && passHead ? `${row.ask} ${row.scope}`.match(/\b[0-9a-f]{7,40}\b/g)?.map(s => git(['rev-parse', '--verify', '--quiet', `${s}^{commit}`])?.trim()).find(s => s && ancestor(passHead, s) && ancestor(s, 'HEAD')) : undefined;
|
||||
if (!stopHead) { process.stderr.write(`gstack-review-plan: --grant ${grant}: no approved row ${grant} naming review_loop_grant and this invocation's stop head (a commit at or after --range-base ${rangeBase}, at or before HEAD) in this branch's approval record (${'file' in loc ? 'gstack-approvals list' : loc.message}); record the owner's grant with gstack-approvals add --ask '... (review_loop_grant <stop head sha>)' first\n`); return EXIT.refused; }
|
||||
}
|
||||
const collected = collectLive(root, base);
|
||||
if (collected.errors.length) {
|
||||
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
|
||||
@@ -171,7 +188,7 @@ function main(): number {
|
||||
const plan = planReview({
|
||||
...collected, ...exclusionInput, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
|
||||
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
|
||||
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines },
|
||||
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant },
|
||||
});
|
||||
const gateScan = { tagged: gateTagged, head };
|
||||
if (has('--json')) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Emits, in order: reviews JSONL with computed review_freshness, ---CONFIG--- (skip_eng_review),
|
||||
# ---HEAD--- (short sha), ---WTREE--- (current working-tree fingerprint from
|
||||
# bin/gstack-wtree, or "unknown"), ---TREE--- (HEAD tree, informational) and
|
||||
# bin/gstack-wtree --review, or "unknown"), ---TREE--- (HEAD tree, informational) and
|
||||
# ---DIRTY--- (tracked-file dirty flag). Consumers grade diff-scoped review
|
||||
# rows using review_freshness; legacy or uncaptured diff evidence cannot fall
|
||||
# back to HEAD equality. Plan rows are unchanged.
|
||||
@@ -19,7 +19,7 @@ SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
gstack_state_root_select
|
||||
GSTACK_HOME="$_gstack_sr_root"
|
||||
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown")
|
||||
WTREE=$("$SCRIPT_DIR/gstack-wtree" --review 2>/dev/null || echo "unknown")
|
||||
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
|
||||
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { reviewFreshness } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
|
||||
+28
-1
@@ -9,6 +9,12 @@
|
||||
# look like a mid-run edit (GST-421); tracked files under it still count, and a
|
||||
# tree without that folder (or with it gitignored) hashes exactly as a plain
|
||||
# `git add -A` would.
|
||||
#
|
||||
# --review (gstack-review-log and gstack-review-read only, GST-985) also leaves
|
||||
# out every root dot-directory that holds no tracked file, such as a test
|
||||
# lane's `.review-units-test-*/` scratch, so scratch appearing or vanishing
|
||||
# mid-review cannot unbind a review. `.github/` and `.gstack/` always stay in:
|
||||
# they hold CI and gate policy. Every other caller keeps the whole tree.
|
||||
# Properties that make this the right staleness fingerprint, vs
|
||||
# `git rev-parse HEAD^{tree}`:
|
||||
#
|
||||
@@ -40,6 +46,13 @@
|
||||
# treat that as "no fingerprint"; a partial hash is never printed.
|
||||
set -euo pipefail
|
||||
|
||||
REVIEW=""
|
||||
case "$#:${1:-}" in
|
||||
0:) ;;
|
||||
1:--review) REVIEW=1 ;;
|
||||
*) echo "Usage: gstack-wtree [--review]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
|
||||
# Resolve the REAL index path BEFORE exporting GIT_INDEX_FILE — with the env
|
||||
# var set, `git rev-parse --git-path index` returns the temp index itself and
|
||||
@@ -95,7 +108,21 @@ fi
|
||||
# call .bun not ignored.
|
||||
EXCLUDE_BUN=""
|
||||
if [ -e "$TOP/.bun" ] && ! git -C "$TOP" check-ignore --no-index -q .bun 2>/dev/null; then EXCLUDE_BUN=':(exclude,top).bun'; fi
|
||||
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ${EXCLUDE_BUN:+"$EXCLUDE_BUN"} 2>&1 >/dev/null); then
|
||||
EXCLUDES=()
|
||||
if [ -n "$EXCLUDE_BUN" ]; then EXCLUDES+=("$EXCLUDE_BUN"); fi
|
||||
if [ -n "$REVIEW" ]; then
|
||||
TRACKED_INDEX="$TMPIDX"
|
||||
if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ]; then TRACKED_INDEX="$REAL_INDEX"; fi
|
||||
for DOTDIR in "$TOP"/.[!.]* "$TOP"/..?*; do
|
||||
NAME=${DOTDIR##*/}
|
||||
case "$NAME" in .git|.github|.gstack|.bun) continue ;; esac
|
||||
if [ ! -d "$DOTDIR" ] || [ -L "$DOTDIR" ]; then continue; fi
|
||||
if GIT_INDEX_FILE="$TRACKED_INDEX" git -C "$TOP" ls-files --error-unmatch -- ":(top,literal)$NAME" >/dev/null 2>&1; then continue; fi
|
||||
if git -C "$TOP" check-ignore --no-index -q -- "$NAME" 2>/dev/null; then continue; fi
|
||||
EXCLUDES+=(":(exclude,top,literal)$NAME")
|
||||
done
|
||||
fi
|
||||
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ${EXCLUDES[@]+"${EXCLUDES[@]}"} 2>&1 >/dev/null); then
|
||||
echo "gstack-wtree: cannot stage the working tree: $(printf '%s' "$ERR" | head -1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -16,6 +16,13 @@ ledger's Open section:
|
||||
in-run rerun turned green (date, case, tier, PR and head, failing attempt, passing
|
||||
attempt, one-line symptom from the failure log).
|
||||
2. The free suites' retried cases in the same window, from the runners' retry output.
|
||||
3. For repositories that run `gstack-flaky`: download the window's `flaky-junit-ledger-*`
|
||||
artifacts (`gh run download <run-id> --pattern 'flaky-junit-ledger-*' --dir ledgers/<run-id>`)
|
||||
and run `gstack-flaky quarantine report --ledger ledgers`. Fix its expired and EXPIRING
|
||||
entries first: fix the test, or renew once with new evidence and the owner named, never
|
||||
a second time. Release each READY_TO_RELEASE entry with
|
||||
`gstack-flaky quarantine release <id> --reason <why> --pr <url>` in a PR of its own.
|
||||
A hard-break line is a real red, not a flake: fix it like any failing test.
|
||||
|
||||
Never add a case that failed twice (that is a real failure, not a flake), and never a
|
||||
harness, infra or incomplete red. Then group the Open section by case, worst repeat
|
||||
@@ -33,5 +40,5 @@ spend of every paid run you made.
|
||||
```
|
||||
|
||||
What it reads: the ledger, the gate runs' flake-records artifacts, the free suites'
|
||||
retry output, the crew rules file. What it starts: one fix PR per cause, each READY'd to
|
||||
retry output, the `gstack-flaky` ledgers and quarantine report, the crew rules file. What it starts: one fix PR per cause, each READY'd to
|
||||
the controller. What it never does: rerun a gate, change a threshold, or merge.
|
||||
+124
-15
@@ -206,7 +206,16 @@ GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1
|
||||
(the PR that set them has the table). A `workflow_dispatch` run may
|
||||
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`),
|
||||
`free_shards` (1–40) and `free_jobs` (1–8) for runner-sizing experiments only;
|
||||
its job summary says so, and it is never cited as a PR or train Free Tests result. Each shard prints
|
||||
its job summary says so, and it is never cited as a PR or train Free Tests result.
|
||||
Each `free-suite` job's GitHub timeout comes from the plan, not a fixed number
|
||||
(#3850): `--ci-plan --job-setup-seconds $FREE_SUITE_SETUP_SECONDS` emits every
|
||||
shard's `timeout_minutes` as the shard's runner wall (the packed wall above),
|
||||
plus the flaky-retry pass's wall (at most 5 files, so the 6-minute base), plus
|
||||
the job's setup and teardown (`ciShardJobTimeoutMinutes`). So GitHub never kills
|
||||
a hung file before the runner names it and retries it. A shard that would need
|
||||
more than 90 minutes fails the plan with `FREE_JOB_TIMEOUT_OVER_CAP`; plan more
|
||||
shards or a higher k instead. The plan step prints the emitted timeouts and adds
|
||||
them to its job summary. Each shard prints
|
||||
`RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and
|
||||
disk sample every 2 seconds, even when the suite fails. The public
|
||||
`TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole
|
||||
@@ -651,15 +660,37 @@ this checkout to read it)`.
|
||||
samples, rationale}` entry per failing dimension (since EVAL_POLICY v3 `mean`
|
||||
holds the panel's gating median), each part capped at 200
|
||||
characters. `sessions[]` (at most 32) summarizes the trial's session-ledger
|
||||
rows. `cost_known: false` marks a record or trial whose harness captured no
|
||||
billing: PTY and Codex sessions never bill, and an SDK session bills only with a
|
||||
terminal `total_cost_usd`. It also marks a trial no result record could be
|
||||
attributed to: a record named by the case id or carrying `case_id` is that
|
||||
case's, and a record named by a display label (`/qa fix loop`) is the shard's
|
||||
only case's when the shard ran one case, so a multi-case shard whose records
|
||||
carry display labels leaves those trials cost-unknown rather than guessing
|
||||
which case paid (`junitCensus` in `scripts/lib/paid-report.ts`). An absent
|
||||
field means known. Census and marathon cost lines print the known sum and
|
||||
rows. `cost_known: false` marks a record or trial whose cost is not fully
|
||||
known; an absent field means known. A record carries it when its harness
|
||||
captured no billing (a Codex session that completed no turn or whose model has
|
||||
no price row). A rule case's trial in the JUnit census (`caseCost` in
|
||||
`scripts/lib/paid-report.ts`) costs its records' sum plus what no record
|
||||
carries: each ledger session without a terminal result (`billed: false`) adds
|
||||
its `streamed_cost_usd`, and a trial with no record adds each billed session's
|
||||
own `cost_usd` (Codex). The trial is cost-unknown when any record says
|
||||
`cost_known: false`, when any of those sessions has no priced cost (a PTY
|
||||
session whose transcript does not price, a `claude -p` session that ended
|
||||
without a result, an aborted Agent SDK session with no usage or an unpriced
|
||||
model), or when it has neither records
|
||||
nor sessions. A case in `MODEL_FREE_CASES` (`timeline-event-flow`, which runs
|
||||
no model) with neither is a known $0. A panel trial (`behavior`, `judge`) from
|
||||
an isolated trial shard keeps the older rule (`trialCostKnown` in
|
||||
`test/helpers/eval-store.ts`): known only with at least one record and every
|
||||
session billed, and streamed cost is not added.
|
||||
|
||||
Records are attributed to a case this way: a record named by the case id or
|
||||
carrying `case_id` is that case's, and a record named by a display label
|
||||
(`/qa fix loop`) is the shard's only case's when the shard ran one case. In a
|
||||
multi-case shard, a display-label record whose `runSkillTest` test name is a
|
||||
registered case id carries `cost_case_id` (`recordE2E`), and the census adds
|
||||
its cost to that case only: the failed record, failure class and model still
|
||||
come from the records named by case id, so no verdict changes. A display-label
|
||||
record without `cost_case_id` in a multi-case shard is not attributed rather
|
||||
than guessed, and its case is cost-unknown when nothing else prices it
|
||||
(`junitCensus`). The report's headline `cost $<x>` adds the streamed session
|
||||
cost no collector record carries, so a newly priced trial counts once.
|
||||
|
||||
Census and marathon cost lines print the known sum and
|
||||
`N of M trial(s) cost unknown`, and a train verdict's `ATTEMPT:` line prints
|
||||
`cost=$<known> known + <n> of <m> case(s) cost unknown` when some but not
|
||||
every case in the train record has a cost (a record with no priced case prints
|
||||
@@ -671,13 +702,33 @@ including `runSharedInteractive`, the PTY session and `runRecordedCodexEval`)
|
||||
appends one best-effort line per armed session to
|
||||
`$GSTACK_EVAL_DIR/session-ledger.jsonl` (`test/helpers/session-ledger.ts`):
|
||||
a stable key `<runner>:<label>#n`, the exact `budget_ms` it armed, monotonic
|
||||
`elapsed_ms`, the `end` reason, sanitized evidence, `billed`, and a compact
|
||||
`elapsed_ms`, the `end` reason, sanitized evidence, `billed`, the billed
|
||||
`model`, `cost_usd` and token classes of a Codex session, and a compact
|
||||
liveness summary (longest in-request silence, the last event kind, any tool open
|
||||
at the end). `claude -p` sessions stream partial messages for this summary;
|
||||
transcripts keep their shape and size (full stream diagnostics only with
|
||||
`publicStreamDiagnostics`). A failed ledger write never fails a trial; a trial
|
||||
without ledger rows has no `sessions` and its headroom is `unknown`, never
|
||||
scored. The ledger rides in each slice artifact beside the shard results.
|
||||
scored. An Agent SDK session that ends without a terminal result (aborted,
|
||||
thrown) also records `streamed_cost_usd`: its streamed assistant usage priced
|
||||
by `streamedUsageCostUsd` (`lib/pricing.ts`), each message id once at its
|
||||
largest output, subagent turns included, `<synthetic>` messages skipped,
|
||||
5-minute cache writes at 1.25x and 1-hour writes at 2x the input rate. The
|
||||
field is absent when no message carries usage or any message's model has no
|
||||
price row, so an unpriced session stays cost-unknown, never $0. A PTY session
|
||||
records `streamed_cost_usd` from its transcript the same way. Both PTY ledger
|
||||
writers price `<CLAUDE_CONFIG_DIR>/projects/*/<id>.jsonl` plus
|
||||
`<id>/subagents/*.jsonl` (`test/helpers/pty/transcript-cost.ts`), where the
|
||||
id is the caller's `--session-id`, else the one transcript that appeared
|
||||
during this launch for its cwd. The launcher adds no argument. Pricing never
|
||||
takes the whole directory, because concurrent PTY tests in one process share
|
||||
one hermetic config dir; two new transcripts for one cwd are ambiguous and stay
|
||||
unknown. A non-hermetic or resumed launch, a caller that opts out
|
||||
(`priceTranscript: false`, the native AskUserQuestion capture, which never
|
||||
reads model transcripts), a missing transcript, no usage, an unpriced model or
|
||||
a read error leaves the field absent (cost-unknown), and a pricing failure
|
||||
never fails the test. The row keeps `billed: false`. The ledger rides in each slice
|
||||
artifact beside the shard results.
|
||||
|
||||
**Quarantine** (`CASE_QUARANTINE`, same file). An entry needs: a per-trial rate
|
||||
below 95% over at least 10 post-policy trials of the case's current input
|
||||
@@ -1232,9 +1283,11 @@ observation never removes a command and a missing or empty artifact never erases
|
||||
one; removing a command is a reviewed edit. While the map is in shadow mode it
|
||||
changes no selection, so a PR that changes only the map selects no paid case.
|
||||
|
||||
Dollar figures are lower bounds. Known cost excludes PTY trials, whose cost is
|
||||
unknown: about 39% of observed case runs in the committed map. The shadow block
|
||||
and `test:health` both say so.
|
||||
Dollar figures are lower bounds. Known cost excludes trials whose cost is
|
||||
unknown: about 39% of observed case runs in the committed map, mostly PTY
|
||||
trials recorded before PTY sessions were priced from their transcripts
|
||||
([Failure causes and details](#failure-causes)). The shadow block and
|
||||
`test:health` both say so.
|
||||
|
||||
- **Shadow block:** `prCoverage.shadow` in the paid-plan manifest lists the gate
|
||||
cases the map would select, would drop and would add, a provenance line per
|
||||
@@ -1750,6 +1803,32 @@ lower-case words in path components under `test/fixtures/`; a substring match
|
||||
separate change with its own touchfile updates; the list is here so that change
|
||||
can be scoped.
|
||||
|
||||
## Agent SDK system prompt
|
||||
|
||||
An Agent SDK caller that leaves out `systemPrompt` doesn't get Claude Code's
|
||||
prompt. It gets the SDK's bare stub, and the stub drops the line that tells the
|
||||
model to run `/skill` through the Skill tool. A skill arm then never loads its
|
||||
skill, so a "no difference" result says nothing about the skill (FOUN-32 found
|
||||
this in another crew's null result).
|
||||
|
||||
**Every caller says which prompt it uses.** `runAgentSdkTest` and every direct
|
||||
SDK `query()` call pass the Claude Code preset,
|
||||
`{ type: 'preset', preset: 'claude_code' }` (also exported as
|
||||
`CLAUDE_CODE_PRESET` from `test/helpers/sdk-system-prompt.ts`; an overlay arm
|
||||
adds `append`), or name why not: `{ type: 'sdk-default', reason }` for a stub
|
||||
provider or a connectivity probe, `{ type: 'custom', text, reason }` for an arm
|
||||
that replaces the prompt. The runner refuses a missing, empty, bare-string or
|
||||
unnamed prompt before it starts, and
|
||||
`test/sdk-system-prompt-tripwire.test.ts` scans test/ and scripts/ for the same
|
||||
mistake in free CI. Arms that compare loadouts, the no-gstack arm included,
|
||||
keep the same preset and differ only in what the arm loads.
|
||||
|
||||
**Record it, and treat a null on the stub as a method bug.** Every eval report
|
||||
and results table names the prompt each arm ran under (`claude_code`,
|
||||
`claude_code + append`, or the named reason). A null or negative result from an
|
||||
arm that ran on the SDK default is FIX-METHOD under R48 ("Why null?"): fix the
|
||||
prompt and rerun before reading it as KEEP or CUT-CANDIDATE.
|
||||
|
||||
## No-gstack arm
|
||||
|
||||
Garry, 2026-10-31: "all of our head to head tests have to test no gstack from
|
||||
@@ -1843,6 +1922,36 @@ it as the diagnostic. Candidate folders and git identity name a project, never
|
||||
the arm. The model panel (`test/skill-e2e-model-panel.test.ts`) runs a
|
||||
`<model>#no-gstack` arm beside each model (GST-468, GST-469).
|
||||
|
||||
## Frontier models under test
|
||||
|
||||
Garry, 2026-11-01 (R54): "I think you are spending too much time with Haiku and
|
||||
Sonnet, whereas GBrain and GStack are really designed to be used with frontier
|
||||
models like Opus and 6.1 Sol... just transfer from Haiku to Sonnet, who gives a
|
||||
shit about that, man?"
|
||||
|
||||
**Models under test are frontier models.** Today that's `claude-opus-5-5` and
|
||||
`gpt-6.1-sol`, plus at most one low-cost primary the preregistration names. Haiku
|
||||
and Sonnet are not models under test. They aren't stand-ins for a frontier model,
|
||||
and an eval has no tier-transfer arms (Haiku against Sonnet, say) unless its
|
||||
preregistration says it is asking about that tier.
|
||||
|
||||
**Verdicts need a frontier result.** A result measured only on Haiku or Sonnet
|
||||
can't carry a KEEP, FIX-METHOD or CUT-CANDIDATE verdict under R48. Rerun it on a
|
||||
frontier model first.
|
||||
|
||||
**What the rule leaves alone.** Judges, cross-family review voices and a
|
||||
harness's shipped housekeeping model aren't models under test, so they keep the
|
||||
model they already use.
|
||||
|
||||
**The check.** `gstack-eval-plan dry-run` prints `CHECK: frontier` whenever an arm
|
||||
is Haiku or Sonnet. When every arm is one, the dry run fails unless
|
||||
`--tier-question "<what the preregistration asks about that tier>"` is passed.
|
||||
When only some arms are, it warns. Either way it prints the whole plan's price
|
||||
on `claude-opus-5-5` and `gpt-6.1-sol`. The dry run doesn't enforce that number:
|
||||
compare it with the brief cap before any arm is swapped. `pilot start` refuses a
|
||||
pilot whose every arm is Haiku or Sonnet unless its dry run recorded a tier
|
||||
question.
|
||||
|
||||
## Running evals as an agent: detach
|
||||
|
||||
Moved verbatim from CLAUDE.md (#2096 size limit). The short rule stays in
|
||||
|
||||
+3
-1
@@ -434,7 +434,9 @@ A scheduled automation starts one lane a day with the
|
||||
runs' flake-records artifacts and the free suites' retried cases, writes one ledger line
|
||||
per event, groups by case, and fixes the worst repeat first with a PR that shows the
|
||||
failure red before green. It never reruns a gate and never touches a threshold, trial
|
||||
count or case kind. On Capy, create it with the automation tool, scheduled in the owner's
|
||||
count or case kind. Where a repository runs `gstack-flaky`, the lane also reads
|
||||
`gstack-flaky quarantine report --ledger <downloaded ledgers>` and fixes expiring quarantine
|
||||
entries before anything else ([flaky.md](flaky.md#the-daily-flaky-pass)). On Capy, create it with the automation tool, scheduled in the owner's
|
||||
timezone, running as a thread in the crew so its report lands in `reports/`.
|
||||
|
||||
The lane's side of the same rule: in CI the gate reruns a case that failed on its own
|
||||
|
||||
@@ -472,8 +472,9 @@ make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
|
||||
(LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line
|
||||
and removes the file an earlier passing run left beside the verdict it rewrites
|
||||
(a profile check that keeps a fuller ok verdict leaves that verdict's file).
|
||||
With `--model <id>`, or `GSTACK_MODEL=<id>` in its environment (what the Capy
|
||||
skill passes), an ok run other than `--for review-voice` also prints `LOADOUT: …`
|
||||
With `--model <id>`, `GSTACK_MODEL=<id>` in its environment, or else an
|
||||
`agent_model` in the session's `GSTACK_PREFERENCES_FILE` outside an eval run (GST-982, with a
|
||||
`LOADOUT_ASSUMED:` note), an ok run other than `--for review-voice` also prints `LOADOUT: …`
|
||||
and `SKILL_ROOT: <dir>` just before `GSTACK_LAUNCH`. For a loadout other than
|
||||
`full`, `GSTACK_LAUNCH_FILE` then names `<verdict file without .json>.loadout-<name>-<digest>.env`,
|
||||
a copy of the verdict's launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`; the
|
||||
|
||||
@@ -93,7 +93,7 @@ What incomplete data means:
|
||||
| headroom `censored` | a sample timed out, so its real duration is at least the budget |
|
||||
| cause `unrecorded` | the trial was written before `failure_cause` existed |
|
||||
| cause `unknown` | the trial failed with no evidence for any other cause |
|
||||
| `cost unknown` | the harness (PTY, Codex) records no billing, or the trial sits in a multi-case shard whose records are named by display label and cannot be attributed to it; only the known sum is shown |
|
||||
| `cost unknown` | a session's cost was not captured (a PTY session without a priced transcript, an unbilled Codex session, a `claude -p` session without a result, an aborted Agent SDK session with no priced streamed usage), or no record or session can be attributed to the trial (a multi-case shard's display-label record without `cost_case_id`); only the known sum is shown |
|
||||
| `history unavailable (...)` | the GitHub history could not be read; `--gate` fails closed |
|
||||
|
||||
## 4. Replay and repair (free)
|
||||
|
||||
+117
-8
@@ -6,7 +6,9 @@ rerun is a **flaky pass**: the job stays green and the flake is recorded on a le
|
||||
the daily flaky pass can fix it at the source. A test that fails twice is red.
|
||||
This is CREW rule 16 ("run the failed test again and note that it is flaky")
|
||||
for any runner that writes JUnit: Playwright, `bun test`, jest, vitest and
|
||||
pytest. gstack's own free suite keeps its file-level retry
|
||||
pytest. A test that keeps flaking while it is fixed can be
|
||||
[quarantined](#quarantine): it still runs, with up to 3 attempts, and never counts
|
||||
as a pass. gstack's own free suite keeps its file-level retry
|
||||
(`GSTACK_FREE_RETRY_FLAKY`, [Flake ledger](TESTING_INTERNALS.md#flake-ledger)).
|
||||
|
||||
## Get started in GitHub Actions
|
||||
@@ -74,6 +76,104 @@ It cannot see a coverage threshold configured without console output (for
|
||||
example `coverageReporter = ["lcov"]` in `bunfig.toml`), which is why this
|
||||
scope is opt-in.
|
||||
|
||||
## Quarantine
|
||||
|
||||
A test that keeps flaking while someone fixes it can be **quarantined** in
|
||||
`.gstack/quarantine.json` at the repository root. Quarantine is a larger
|
||||
attempt budget, never a skip: the test still runs on every run, is listed as
|
||||
quarantined in the output, and never counts as a pass.
|
||||
|
||||
**How a quarantined test is judged.** The verdict rule from
|
||||
[Rerun scope](#rerun-scope) does not change: an attempt counts only when the
|
||||
runner itself reports a full green run. When every test an attempt failed is a
|
||||
live quarantined test, and nothing else is wrong with that attempt (no kill, no
|
||||
failure outside the report, no first-run test missing), the whole command may
|
||||
run again, up to 3 attempts in total. The run is green only when one of those
|
||||
attempts is fully green. A quarantined test that fails all 3 is a **hard
|
||||
break**, and the run is red. A test that is not quarantined keeps its one
|
||||
rerun. A red attempt never turns green because its only reported failure is a
|
||||
quarantined test, because the runner's exit status can come from things the
|
||||
report does not show (a coverage threshold, a fail-on-flaky policy, a global
|
||||
setup). With `--rerun-scope test` the quarantined test gets the same 3
|
||||
attempts on the targeted path, with the same fallback to the whole command.
|
||||
When the runner retries on its own (Playwright `retries`), its attempts are
|
||||
the budget and nothing is rerun again.
|
||||
|
||||
**Missing results.** A quarantined id missing from a run that included its
|
||||
file (and, for Playwright, its project) is red. A run that did not include the
|
||||
file, such as a shard or a diff selection, is not.
|
||||
|
||||
**Read from the base branch.** `run --base <ref>` reads the registry from
|
||||
that commit instead of the working tree. Pull-request CI passes its target
|
||||
branch, so a change cannot quarantine the test it breaks. `check --base`
|
||||
compares against the merge base, so check out full history for it:
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Quarantine registry
|
||||
run: '"$RUNNER_TEMP/gstack/bin/gstack-flaky" quarantine check --base "origin/${{ github.base_ref }}"'
|
||||
- name: E2E
|
||||
run: |
|
||||
"$RUNNER_TEMP/gstack/bin/gstack-flaky" run --base "origin/${{ github.base_ref }}" --junit results/junit.xml -- npx playwright test --reporter=line,junit
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `gstack-flaky quarantine add <id> --owner <who> --ledger <url> --class <class> --reason <text> --evidence <url>… --junit <file> [--days <n>]` | Adds an entry that expires in `--days` (default 7, at most 14). `--junit` counts the suite for the cap and confirms the id exists; `gstack-flaky ids` prints the ids. |
|
||||
| `gstack-flaky quarantine renew <id> --owner <who> --evidence <url> [--days <n>]` | One renewal of up to 7 days from the later of now and the current expiry, with evidence not already on the entry and the owner named again. A second renewal is refused. |
|
||||
| `gstack-flaky quarantine release <id> --reason <text> --pr <url>` | Moves the entry to `released` with the date, the reason and the PR. |
|
||||
| `gstack-flaky quarantine check [--base <ref>] [--junit <file>]` | Validates every entry and the cap; with `--base`, also what this change did since the merge base. Exits 1 with one `QUARANTINE_REFUSED:` line per problem. |
|
||||
| `gstack-flaky quarantine report [--ledger <file\|dir>…] [--base <ref>]` | Lists expired entries, then live entries soonest-expiring first with days left. With downloaded ledgers it adds recent outcomes and marks entries with 10 consecutive clean passes `READY_TO_RELEASE`; without `--ledger` it says the exit rule was not checked. |
|
||||
|
||||
### Rules
|
||||
|
||||
- **Every entry is owned and expires.** An entry holds `owner`, `ledger`
|
||||
(the ledger line or issue tracking the fix), `failureClass`, `reason`,
|
||||
`evidence`, `enteredAt`, `expiresAt`, `renewals` and `suiteTests` (the
|
||||
suite size `add` counted). It lasts 7 days by default and at most 14 from
|
||||
entry, plus one renewal of at most 7 days.
|
||||
- **Expired means not quarantined.** An expired entry stops applying without
|
||||
an edit: `run` treats the test as an ordinary test and prints
|
||||
`FLAKY_QUARANTINE_EXPIRED` with the renew and release commands. An entry
|
||||
that breaks a rule (for example one that lasts 30 days) is ignored the same
|
||||
way, with `FLAKY_QUARANTINE_IGNORED`.
|
||||
- **Cap.** Live entries are capped at 10% of the suite's tests, at least 1,
|
||||
and 5 for a suite under 50 tests. `add` refuses past the cap; `check`
|
||||
enforces it against `--junit` when given, else against the smallest
|
||||
`suiteTests` on a live entry.
|
||||
- **Classes.** `detector`, `harness`, `timing` and `model-latency`.
|
||||
`product` is refused (a product defect is fixed, not quarantined), and so is
|
||||
`infra` (an infrastructure red is rerun or fixed as infrastructure).
|
||||
- **`check --base` refuses** an entry added in the same change that edits its
|
||||
test's file, an entry that disappears without a release record, a release
|
||||
record that was removed, and a changed `enteredAt` or rewritten renewal
|
||||
history.
|
||||
- **Exit rule.** 10 consecutive clean passes (`quarantined-pass` with no
|
||||
failed attempt) on the ledgers since the entry was added. Release the entry
|
||||
with the fix PR.
|
||||
- **Separate from `CASE_QUARANTINE`.** gstack's own paid-eval quarantine
|
||||
(`CASE_QUARANTINE` and `EVAL_POLICY`,
|
||||
[TESTING_INTERNALS.md](TESTING_INTERNALS.md)) is a different registry with
|
||||
its own rules for eval cases. The two share no schema, and `gstack-flaky`
|
||||
neither reads nor writes it.
|
||||
|
||||
### The daily flaky pass
|
||||
|
||||
Download the ledger artifacts of the last week's runs and read them with
|
||||
`report`; fix expired and `EXPIRING` entries first (fix the test, or renew
|
||||
once with new evidence), release `READY_TO_RELEASE` entries, then fix the
|
||||
worst repeat on the ledger:
|
||||
|
||||
```bash
|
||||
for id in $(gh run list --workflow e2e.yml --limit 50 --json databaseId -q '.[].databaseId'); do
|
||||
gh run download "$id" --pattern 'flaky-junit-ledger-*' --dir "ledgers/$id" || true
|
||||
done
|
||||
gstack-flaky quarantine report --ledger ledgers
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **One rerun, small failures only.** A failed test gets one rerun when the
|
||||
@@ -101,8 +201,9 @@ scope is opt-in.
|
||||
- **Ids.** A test is `<file>::<project>::<describe > title>`, with the project
|
||||
empty outside Playwright, so a test in two browser projects is two tests.
|
||||
`gstack-flaky ids --junit <file>` prints them.
|
||||
- **Ledger.** One JSON line per flaky pass (`kind: flaky-pass`) and per test
|
||||
that failed its rerun (`rerun-fail`), with the tree, commit, branch, run URL,
|
||||
- **Ledger.** One JSON line per flaky pass (`kind: flaky-pass`), per test
|
||||
that failed its rerun (`rerun-fail`) and per quarantined test on every run
|
||||
(`quarantined-pass`, `quarantined-fail`, `hard-break`), with the tree, commit, branch, run URL,
|
||||
attempts, the rerun's scope (`test` or `command`) and the first lines of
|
||||
the error. Ids, paths and failure text go through gstack's redaction engine
|
||||
first; when the scan cannot run, they are withheld. The path is `--ledger`,
|
||||
@@ -113,15 +214,17 @@ scope is opt-in.
|
||||
|
||||
## Output and exit codes
|
||||
|
||||
One `FLAKY: <flaky-pass|fail|rerun-fail> <id> attempts=<…> (<reason>)` line per
|
||||
test that failed at least once, `FLAKY_RERUN: scope=command (<why>)` when the
|
||||
rerun repeated the whole command, then
|
||||
`FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>`.
|
||||
One `FLAKY: <flaky-pass|fail|rerun-fail|quarantined-pass|quarantined-fail|hard-break> <id> attempts=<…> (<reason>)`
|
||||
line per test that failed at least once and per quarantined test,
|
||||
`FLAKY_RERUN: scope=command (<why>)` when the rerun repeated the whole command, then
|
||||
`FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> quarantined=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>`.
|
||||
`failed` counts every outcome but `flaky-pass` and `quarantined-pass`; `quarantined`
|
||||
counts the quarantined tests' outcomes, which are never counted as passes.
|
||||
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 0 | Every test passed, flaky passes included |
|
||||
| 1 | A test failed (after its rerun, or with no rerun), including `FLAKY_UNREPORTED_FAILURES` |
|
||||
| 1 | A test failed (after its rerun, or with no rerun), including `FLAKY_UNREPORTED_FAILURES`, a quarantined test's hard break or missing result, and a refused `quarantine` command |
|
||||
| 2 | Usage error, including a `--junit` path that is not a `.xml` file, or an existing file there that is not a readable JUnit report |
|
||||
| 3 | No usable report, a crash, or a killed runner (a code below on stderr) |
|
||||
|
||||
@@ -135,3 +238,9 @@ rerun repeated the whole command, then
|
||||
| `FLAKY_COMMAND_NOT_STARTED` | 3 | The command could not be started | Check the argv after `--` |
|
||||
| `FLAKY_UNREPORTED_FAILURES` | 1 | The runner counted failures or errors its JUnit omits | Fix the load error or the error outside any test: a real red |
|
||||
| `FLAKY_LEDGER_WRITE_FAILED` | — | The ledger could not be appended (warning) | Check the path; the verdict stands |
|
||||
| `FLAKY_QUARANTINE_INVALID` | 3 | `.gstack/quarantine.json` is not valid JSON or not a schema 1 registry | Fix the file; `quarantine check` names the problem |
|
||||
| `FLAKY_QUARANTINE_BASE` | 3 | `--base` does not resolve to a commit (exit 2 for a value starting with `-`), or `check` finds no merge base | Fetch the base branch; `check --base` needs `fetch-depth: 0` |
|
||||
| `FLAKY_QUARANTINE_REFUSED` | 1 | `add`, `renew` or `release` broke a rule | Read the reason; [Quarantine](#quarantine) lists the rules |
|
||||
| `FLAKY_QUARANTINE_USAGE` | 2 | An unknown `quarantine` command or option, or a missing value | `gstack-flaky quarantine --help` |
|
||||
| `FLAKY_QUARANTINE_EXPIRED` | — | Notice: an expired entry no longer applies | Fix the test, renew once, or release; the line prints both commands |
|
||||
| `FLAKY_QUARANTINE_IGNORED` | — | Notice: an entry breaks a rule and does not apply | Fix the entry; `quarantine check` names the rule |
|
||||
+8
-7
@@ -9,7 +9,7 @@ One model has its own loadout: Claude Opus 5.5 gets `lite` ([below](#lite-on-cla
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| Module | One of four kinds. `skill:<dir>` is a whole skill. `section:<skill>/<id>` is one entry of `<skill>/sections/manifest.json`. `overlay:<name>` is one `model-overlays/<name>.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:<id>` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). |
|
||||
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
|
||||
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the Claims Need Evidence rule, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
|
||||
| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
|
||||
| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
|
||||
| Model map | `models` in `modules/loadouts.json`: an exact model id to a loadout, checked before rungs. The id also matches its dated snapshot (`-YYYYMMDD`) and `-latest`, never a point release or any other suffix (`claude-opus-5-5` matches `claude-opus-5-5-20261101`, not `claude-opus-5-50` or `claude-opus-5-5-1`). |
|
||||
@@ -40,21 +40,22 @@ bin/gstack-modules install --full-root "$HOME/.claude/skills" --model claude-opu
|
||||
|
||||
## Lite on Claude Opus 5.5
|
||||
|
||||
`lite` is `full` minus eleven coaching pieces of the shared preamble: voice, writing style, completeness, confusion, evidence, context health, question tuning, search before building, repo mode, brain health and context recovery. It keeps every pinned piece, the upgrade check (the PROACTIVE and skill-prefix rules) and brain sync (the privacy stop-gate). Only `claude-opus-5-5` maps to it, because only Opus 5.5 has been measured: office-hours showed no loss on Opus 5.5 (GSTA-161 preamble run), and the land-and-deploy confirmation (GSTA-161, PR 1b) compared full, lite and no gstack on the same tasks. Haiku 5.5 leaned slightly against a larger cut on the same office-hours run, so small models stay on `full`. Two skills point at a preamble section lite drops: office-hours and plan-ceo-review name ETHOS.md through the "Search Before Building" section, so under lite they find ETHOS.md in the gstack checkout instead.
|
||||
`lite` is `full` minus ten coaching pieces of the shared preamble: voice, writing style, completeness, confusion, context health, question tuning, search before building, repo mode, brain health and context recovery. It keeps every pinned piece, including the Claims Need Evidence rule (GST-944: the honest-work design never retires it while gstack reports evidence, and it adds 804 bytes back to lite's 16,175-byte shared preamble), the upgrade check (the PROACTIVE and skill-prefix rules) and brain sync (the privacy stop-gate). Only `claude-opus-5-5` maps to it, because only Opus 5.5 has been measured: office-hours showed no loss on Opus 5.5 (GSTA-161 preamble run), and the land-and-deploy confirmation (GSTA-161, PR 1b) compared full, lite and no gstack on the same tasks. Haiku 5.5 leaned slightly against a larger cut on the same office-hours run, so small models stay on `full`. Two skills point at a preamble section lite drops: office-hours and plan-ceo-review name ETHOS.md through the "Search Before Building" section, so under lite they find ETHOS.md in the gstack checkout instead.
|
||||
|
||||
### How gstack picks a loadout
|
||||
|
||||
gstack never guesses the model. It uses, in order:
|
||||
gstack does not detect the running model. It uses, in order:
|
||||
|
||||
1. An explicit loadout: `./setup --loadout <name>` on Claude Code, or `bin/gstack-modules install --loadout <name>`. It is served as asked and bound to no model.
|
||||
2. An explicit model id: `--model <id>` on the Capy installer (`scripts/install.sh --model <id>`, which passes it to `bin/gstack-capy-install`), else `GSTACK_MODEL`, which the installer also reads.
|
||||
3. Nothing: `full`.
|
||||
3. On Capy only, with neither and outside an eval run: the owner's `agent_model` (this machine's `config.yaml`, else the Drive skill's `preferences.yaml`), as `gstack-models resolve --role agent` resolves it (`opus` is the newest Opus). This is an assumption, not a detection, and the installer prints `LOADOUT_ASSUMED:` beside it. `host` or no preference means no model.
|
||||
4. Nothing: `full`.
|
||||
|
||||
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so a Sonnet subagent of an Opus session gets `full` unless its own install names Opus.
|
||||
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so on Claude Code a Sonnet subagent of an Opus session gets `full` unless its own install names Opus. On Capy every session shares the owner's preference, so a session on another model sets `GSTACK_MODEL=<its id>` to get its own loadout.
|
||||
|
||||
### Capy
|
||||
|
||||
The Capy skill passes the thread's model id in the environment: `GSTACK_MODEL=<id> bash scripts/install.sh --check --for <skill>`. The variable, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
|
||||
A plain `bash scripts/install.sh --check --for <skill>` picks the loadout from the owner's `agent_model` preference, so the skill needs no model sentence (`source=preferences` on the line below, or `source=config` when this machine's `config.yaml` sets `agent_model`). The preference names the model Garry starts sessions on, not the model reading the skill, so the installer says so in a `LOADOUT_ASSUMED:` line, and it skips the preference inside an eval run (`GSTACK_EVAL_DIR` or `EVALS_RUN_ID` set), where the model under test may differ. A session on another model names its own with `GSTACK_MODEL=<id>`, which wins over the preference and, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
|
||||
|
||||
```
|
||||
LOADOUT: lite model=claude-opus-5-5 source=GSTACK_MODEL digest=3f2a9c01d4e7 reused=yes
|
||||
@@ -63,7 +64,7 @@ SKILL_ROOT: /home/user/.gstack/loadouts/lite-3f2a9c01d4e7
|
||||
|
||||
Read skills from `SKILL_ROOT` (`<root>/gstack/SKILL.md` routes, `<root>/<skill>/SKILL.md` runs) and source the printed `GSTACK_LAUNCH_FILE`, which adds `GSTACK_LOADOUT_SKILL_ROOT`. For `full`, `SKILL_ROOT` is `~/.claude/skills` and the launch file is the install verdict's own.
|
||||
|
||||
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `<loadout>-<digest12>` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
|
||||
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. It carries over two of the full render's install settings: gbrain detection (`gbrain-detection.json` in the state root, so the brain context-load and save-results blocks appear when gbrain is detected) and `gstack-config` `disabled_skills` (the router omits those skills). The digest covers the selection, the bound model, the full root, host, overlay, plan mode, gbrain detection, disabled skills and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `<loadout>-<digest12>` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
|
||||
|
||||
### The start line carries the model
|
||||
|
||||
|
||||
+16
-4
@@ -74,7 +74,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `runner` | the local test command with `{files}` (gstack: `bun test --timeout=30000 {files}`, the 30 s per-test timeout CI uses); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p <name>` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) |
|
||||
| `runner` | the local test command with `{files}` (gstack: `bun test --timeout=30000 {files}`, the 30 s per-test timeout CI uses); unset, it is detected (bun, vitest or jest for JS/TS files, pytest when python3 can import it (else `pytest` or `python3` is named missing), `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p <name>` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; a JS/TS test file that imports `@playwright/test` at the start of a line runs as `playwright test --reporter=list` from its nearest package through that same package manager, whatever the unit runner is, a type-only import (`import type …`, or one whose every specifier is `type X`) does not count, file arguments are passed as escaped regexes anchored to the path (Playwright reads them as regexes), and a run where every test fails and Playwright says its browser is not installed is `PREGATE_ENVIRONMENT` (a partial install, where some tests pass, stays the diff's failure); every other test keeps the root detection, split by language when no one runner fits them all, so `.mjs` and `.py` root tests each get their own runner); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) |
|
||||
| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences). A `[]` that matches a deleted test does not skip it: the deleted test still runs its owning suite, and a file naming it that `[]` covers (a durations list) counts as covered. With this key present, even empty, the policy [declares lanes](#lane-requirements) |
|
||||
| `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test |
|
||||
| `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) |
|
||||
@@ -220,6 +220,14 @@ Two stages with separate clocks, both printed:
|
||||
other line holding the old value still fail. Only the row itself is exempt:
|
||||
the old value after its closing bracket on the same line still fails. Rows in object form (`{ v: 45 }`) or spread over several
|
||||
lines are not recognized as history rows.
|
||||
A one-character old value (`2`) counts only in a statement that names the
|
||||
constant: on the name's line, or on up to three lines that continue it (the
|
||||
line before ends with `(`, `[`, `{`, `,`, `.`, `=` or another operator, or the
|
||||
line starts with `.`, `)`, `]`, `}` or an operator), never on a comment line, plus the history rows
|
||||
above. A `$2` in a shell stub, a comment, or `export default 2` in a fixture
|
||||
elsewhere in the file is not a consumer. A call-site name such as `attempts`
|
||||
or `limit` is an ordinary word, so for it the file must also name the changed
|
||||
module.
|
||||
|
||||
- `timing`: added or changed test lines that compare an elapsed wall-clock or
|
||||
CPU time against a bound, or sleep and then assert, print
|
||||
@@ -437,7 +445,7 @@ test-tier command:
|
||||
Per-file lines come from a progress adapter where the runner's reporter
|
||||
provides them: `bun test` (its `<file>:` headers) is the only adapter today;
|
||||
a one-file run (each serial file) reports its file from the launch for any
|
||||
runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test, cargo test) get heartbeats
|
||||
runner. Other runners (node --test, vitest, jest, pytest, playwright test, flutter test, dart test, cargo test) get heartbeats
|
||||
only. A heartbeat follows each 60 s with no progress line;
|
||||
- is killed as **stalled** when its log does not grow for the stall timeout
|
||||
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
|
||||
@@ -994,7 +1002,7 @@ failure the branch caused carries `cause: "diff"` (`PREGATE_LANE_FAILED`,
|
||||
|
||||
| Kind | Evidence |
|
||||
|---|---|
|
||||
| `missing-binary` | exit 127, `command not found`, a spawn `ENOENT`, a runner tool `not on PATH` (dart, flutter, cargo, a package manager) |
|
||||
| `missing-binary` | exit 127, `command not found`, a spawn `ENOENT`, a runner tool `not on PATH` (dart, flutter, cargo, a package manager, `python3`, or `pytest` when python3 cannot import it), Playwright's browsers not installed |
|
||||
| `network` | `ENOTFOUND`, `ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `EAI_AGAIN`, `Could not resolve host`, `fetch failed`, a TLS handshake or certificate failure |
|
||||
| `quota` | `quota`, `429 Too Many Requests`, `rate limit`, `insufficient_quota` (a vCPU quota on the remote backend) |
|
||||
| `disk` | `ENOSPC`, `No space left on device`, `EDQUOT` |
|
||||
@@ -1005,7 +1013,11 @@ The classifier is conservative. A local lane that exited non-zero is read from
|
||||
its output **only when its runner reported no test summary** (nothing ran) and
|
||||
the runner was not seen starting a test file; a run whose summary counts `N
|
||||
ran, M failed` is the diff's failure whatever its log says, so a test that
|
||||
failed on `ECONNREFUSED` is still a red test. A remote gate that exited
|
||||
failed on `ECONNREFUSED` is still a red test. One exception: when every
|
||||
failed run is a Playwright run in which every test failed and Playwright says
|
||||
its browsers are not installed, the lane is `missing-binary` with `proof:
|
||||
"output"` (a partial install, where some tests pass, stays the diff's
|
||||
failure). A remote gate that exited
|
||||
without a `PREGATE_REMOTE:` line is read from its **preamble only** (its first
|
||||
40 lines, before any test output could stream): a VM refused for quota or an
|
||||
unreachable API there never ran the tests, so it is environment and the
|
||||
|
||||
+19
-2
@@ -98,9 +98,19 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
||||
finishes with its open notes listed in the PR body and receipt, or stops for
|
||||
the owner when a blocking finding is open, or was fixed in round 2 and is
|
||||
unverified, or a round-2 fix edited a gate-class path, naming it (path,
|
||||
severity, problem). A third round is the owner's
|
||||
severity, problem). The cap limits fix rounds, not Step 11's voices: on gate
|
||||
class a stop waits for Step 11's adversarial, outside and targeted voices
|
||||
(`NEXT: voices`), so gate code never reaches the owner's "ship with open
|
||||
notes" without them (GST-967). A third round is the owner's
|
||||
grant of one scoped fix-check (recorded in `review_loop_exception`), never the
|
||||
loop's. The cap approves nothing.
|
||||
loop's: once `gstack-approvals add` records the grant (its ask or scope
|
||||
naming `review_loop_grant` and the stop head's sha),
|
||||
`gstack-review-plan --grant <approval id> --range-base <round 2's PASS_HEAD>`
|
||||
plans that fix-check in place of the stop after a round-2 full pass with a
|
||||
fixed blocking finding or a gate-class edit, and refuses any other row,
|
||||
including a grant for a stop head before `--range-base` (an earlier /ship).
|
||||
A fix-check never earns another, so the grant cannot repeat, and a stop after
|
||||
a round-2 fix-check still goes to the owner. The cap approves nothing.
|
||||
|
||||
<a id="transitions"></a>
|
||||
### Transitions
|
||||
@@ -122,12 +132,19 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
||||
| a full pass that leaves a blocking finding open (fixed or not) | stop: name it for the owner |
|
||||
| a fix-check that found nothing blocking | done |
|
||||
| a fix-check that found a blocking finding | stop: name it for the owner |
|
||||
| a stop (any row above) on gate class before Step 11's voices ran | Step 11's voices (adversarial, outside, targeted), then ask again with `--adversarial-ran` |
|
||||
| a round-2 full pass that stops for a fixed blocking finding or a gate-class edit, with the owner's recorded grant (`--grant <approval id>`) | one scoped fix-check (round 3), after Step 11 on gate class |
|
||||
|
||||
A **fix-check** is one reviewer reading the touched hunks, the fix list, the
|
||||
test results and the callers of every changed symbol, at full effort. It is
|
||||
logged as a `review-fix-check` row, so it never counts as a pass. No army, no
|
||||
Red Team and no outside voice run on it.
|
||||
|
||||
`NEXT: voices` runs Step 11 as `done` does, from the last full pass's
|
||||
manifest; /ship then asks the plan again with `--adversarial-ran` (a blocking
|
||||
finding the voices queued counts as `--blocking-open`, never a new Step 9), and
|
||||
the stop or the granted fix-check it names stands.
|
||||
|
||||
<a id="scoped-pass"></a>
|
||||
A **scoped pass** is pass 2 after a blocking fix when pass 1 and the diff both
|
||||
ran below full depth (no gate class, no depth error, no `--depth-floor full`).
|
||||
|
||||
+17
-5
@@ -601,13 +601,20 @@ gstack-ship-queue wave settle --wave <nonce> [--dry-run] # after the wave
|
||||
|
||||
- **When.** `wave plan` counts the live READY PRs (open, head equal to their
|
||||
READY receipt, CI green at that head; a READY head whose CI is red, still
|
||||
running or absent prints a `NOT_GREEN` line and stays out of the plan). A
|
||||
READY PR whose CI, head or diff cannot be read fails the whole plan with
|
||||
`QUEUE_PROOF_MISSING`. Over the
|
||||
running or absent prints a `NOT_GREEN` line and stays out of the plan; a
|
||||
READY head that does not merge onto main with the train's merge rules prints
|
||||
`NOT_MERGEABLE` with the conflicting files and stays out too, while a
|
||||
conflict only in the CHANGELOG, which the train merges, does not count). A
|
||||
READY PR whose CI, head, diff or merge onto main cannot be read fails the
|
||||
whole plan with `QUEUE_PROOF_MISSING`. Over the
|
||||
trigger (6) it prints `WAVE_ADVISED` and suggested groups of 3 to 6: PRs
|
||||
whose changes to a common code file conflict (`git merge-tree` cannot merge
|
||||
the two heads, or the check itself fails) always share a group, so that
|
||||
conflict is resolved once. Hub
|
||||
conflict is resolved once. A PR that renames a file and one that edits the
|
||||
old path are linked when the edit conflicts at the new path. A group of
|
||||
linked PRs is never split across waves: one larger than the wave maximum
|
||||
prints `WAVE_OVERSIZE` with no build command (land part of it as single
|
||||
slots, then plan again). Hub
|
||||
files never link PRs: anything under `docs/`, Markdown (generated skill
|
||||
renders included; their `.tmpl` sources still link) and every `.json` or
|
||||
`.jsonl` file (ledgers and registries). Most PRs append to them, and
|
||||
@@ -1228,7 +1235,12 @@ block `gstack-approvals` renders (its heading, the Date | Asked | Exact words
|
||||
table and the `Source: gstack-approvals record` line) quotes the owner's
|
||||
words, not this PR's numbers, so it is skipped: "one Step 11 pass" in that table
|
||||
is not a `pass` claim. Text you write under the heading after that block, and
|
||||
a `## Approvals` example inside a code fence, are still checked. Data PRs (evals,
|
||||
a `## Approvals` example inside a code fence, are still checked. The
|
||||
`Review-loop exception: …` line the receipt's own exception requires is
|
||||
skipped the same way (GST-977): it quotes the owner's reason, so "outside 1 and
|
||||
2 skip detector" is not a `2 skip` claim. Only a line that matches the rendered
|
||||
line exactly is skipped, inside a code fence too, as the receipt check accepts
|
||||
a fenced copy; the same words inside a longer line are still checked. Data PRs (evals,
|
||||
measurements, sweeps) run it before READY so the body's numbers are the run's.
|
||||
|
||||
<a id="review-exclude-paths"></a>
|
||||
|
||||
+4
-1
@@ -963,7 +963,10 @@ arms, decoys, stop rules, cap) and `--check` refuses to continue while a field i
|
||||
blank (`PREREG_INCOMPLETE`). `arms` resolves the `eval-arms` model role against
|
||||
`lib/pricing.ts` and stops on an unpriced model (`EVAL_PRICE_MISSING`); `dry-run`
|
||||
runs every row through the stub model for $0 and flags malformed rows
|
||||
(`EVAL_DRY_RUN_FAILED`); `pilot start|settle|release|report` runs three priced
|
||||
(`EVAL_DRY_RUN_FAILED`), and refuses a plan whose every arm is Haiku or Sonnet
|
||||
unless `--tier-question` names what it asks about that tier, re-costing it on
|
||||
frontier models ([frontier models under test](TESTING_INTERNALS.md#frontier-models-under-test));
|
||||
`pilot start|settle|release|report` runs three priced
|
||||
items, records actual spend, extrapolates each priced arm to the full set against
|
||||
the cap (`EVAL_BUDGET_EXHAUSTED`) and prints the spend table the approval question
|
||||
shows. An arm on a subscription with no extra charge is declared with
|
||||
|
||||
@@ -19,7 +19,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
|
||||
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
|
||||
| `gstack-evidence` | **Verification-evidence ledger** — `run --label <lane> -- <cmd>` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint (tracked and untracked files that are not gitignored, minus an untracked `.bun/` folder at the repo root, which Bun can create during a run); a run whose files changed while it ran is recorded without a fingerprint and with up to five of the changed paths, which `check` names in its STALE reason. `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
|
||||
| `gstack-flaky` | **Flaky-test rerun for any JUnit runner** — `run --junit <file.xml> -- <command>` runs your test command (Playwright, `bun test`, jest, vitest, pytest) and, when at most `--max-reruns` (3) tests failed and the runner made no retries of its own, reruns the whole command once (`--rerun-scope test` opts in to rerunning only the failed tests). A test that fails and then passes on that rerun is a flaky pass: the run stays green and one redacted line per flaky pass or failed rerun goes on a ledger (`GSTACK_FLAKE_LEDGER_JUNIT`, default `<state root>/flake-ledger-junit.jsonl`). A missing or malformed report, a crash or a killed runner never passes. `ids --junit <file>` prints a report's test ids. Exit 0 pass, 1 a test failed, 2 usage, 3 no usable report. [Guide](flaky.md). |
|
||||
| `gstack-flaky` | **Flaky-test rerun for any JUnit runner** — `run --junit <file.xml> -- <command>` runs your test command (Playwright, `bun test`, jest, vitest, pytest) and, when at most `--max-reruns` (3) tests failed and the runner made no retries of its own, reruns the whole command once (`--rerun-scope test` opts in to rerunning only the failed tests). A test that fails and then passes on that rerun is a flaky pass: the run stays green and one redacted line per flaky pass or failed rerun goes on a ledger (`GSTACK_FLAKE_LEDGER_JUNIT`, default `<state root>/flake-ledger-junit.jsonl`). A missing or malformed report, a crash or a killed runner never passes. `quarantine add|renew|release|check|report` keeps `.gstack/quarantine.json`: a quarantined test still runs, gets up to 3 attempts of the whole command, passes only on a full green attempt and is never counted as a pass (`run --base <ref>` reads the registry from the base branch). `ids --junit <file>` prints a report's test ids. Exit 0 pass, 1 a test failed, 2 usage, 3 no usable report. [Guide](flaky.md). |
|
||||
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait <pr> [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `<state root>/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
|
||||
| `gstack-review-bots` | **Review-bot comments** — `fetch [--pr <n>]` reads every root line comment, conversation comment and newest review body from each bot in `pr_wait_gating_bots` and `pr_wait_advisory_bots` (Bot accounts only, all pages, through `gh`) into a private directory with per-bot counts, or prints `REVIEW_BOTS: skip` or `REVIEW_BOTS: error` (never a false zero). `record --dir <dir> --id <comment id> --type fix\|fp\|already-fixed --category <category>` appends one triage outcome to the project and global `review-bot-history.md`, taking the bot and file from the fetched comment. `/review` Step 2.5 and `/ship` Step 10 run it. [Triage reference](../review/greptile-triage.md). |
|
||||
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue <n>`, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
|
||||
|
||||
+3
-1
@@ -105,7 +105,9 @@ lives in `projects/<slug>/`. `bin/gstack-slug` picks the slug from the git
|
||||
- Without a remote, the project root's directory name is used.
|
||||
|
||||
A bucket can also hold a user-owned `pregate.json` policy, for a repo that keeps
|
||||
`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)).
|
||||
`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)), and
|
||||
`pregate/runs/<run id>/`, the scratch each pregate run writes (lane and guard
|
||||
logs, the remote gate's file list); old runs are pruned when a new one starts.
|
||||
|
||||
The first slug gstack resolves in a directory sticks (it is cached under
|
||||
`slug-cache/`), so a project keeps its bucket when it adds a remote later.
|
||||
|
||||
@@ -10,7 +10,7 @@ gstack has two support tiers. Full support gets the whole test suite, paid evals
|
||||
|
||||
## Minimal support
|
||||
|
||||
- **Windows** (Git Bash or MSYS): with a default Git for Windows install, `./setup` installs, and the core skills start without errors. The core skills are `/ship`, `/review`, `/qa-only`, `/investigate` and `/browse` on gstack's own browser. Once the `windows-smoke` CI job lands, it checks this on every pull request (see Windows CI below).
|
||||
- **Windows** (Git Bash or MSYS): with a default Git for Windows install, `./setup` installs, and the core skills start without errors. The core skills are `/ship`, `/review`, `/qa-only`, `/investigate` and `/browse` on gstack's own browser. One required CI job, `windows-smoke`, checks this on every pull request (see Windows CI below).
|
||||
- **Gemini models:** the core workflows run to the end without errors when Gemini is the host agent or a reviewer. We check this by hand or with one cheap smoke run, not with an eval cell.
|
||||
|
||||
That is the whole promise. Other skills, optional parts such as `/cso`'s native helper, non-default shell settings and speed are not covered.
|
||||
@@ -23,8 +23,9 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native
|
||||
|
||||
## Windows CI
|
||||
|
||||
- **Today:** the six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs on every pull request and every push to main, and it is a required check.
|
||||
- **Once `windows-smoke` lands:** `windows-smoke` becomes the required Windows check on pull requests. The six-shard suite then runs nightly on main, or when someone starts it by hand, and never blocks a pull request. A red nightly run gets a fix only when it shows an install, startup or core-workflow break.
|
||||
- `windows-smoke` is the required Windows check on pull requests.
|
||||
- The six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs nightly on main, or when someone starts it by hand, and never blocks a pull request.
|
||||
- A red nightly run gets a fix only when it shows an install, startup or core-workflow break.
|
||||
|
||||
## What doesn't change
|
||||
|
||||
|
||||
+27
-8
@@ -570,6 +570,9 @@ the base has moved.
|
||||
**Meaning.** The working tree changed between the pass's `gstack-review-log --start` and
|
||||
its logged row: an edit, commit or merge landed while reviewers were still reading. The
|
||||
reviewers may not have seen that change, so the row's `review_binding.state` is `changed`.
|
||||
Review records fingerprint with `gstack-wtree --review`, which skips untracked root dot-folders
|
||||
that hold no tracked file (never `.github/` or `.gstack/`), so a test lane's scratch folder alone
|
||||
does not move the tree.
|
||||
Step 11.5 cannot bind it and inserts `9 → 10 → 11 → 11.5`. Logging the same results again
|
||||
cannot repair it.
|
||||
|
||||
@@ -2595,12 +2598,24 @@ case with `bun run eval:pass-rates --headroom --case <id>`.
|
||||
<a id="cost-unknown"></a>
|
||||
### `cost unknown (no billing captured for any of <N> trial(s))` / `cost $<x> known + <N> of <M> trial(s) cost unknown`
|
||||
|
||||
**Meaning.** PTY and Codex sessions record no billing, so their cost is
|
||||
unknown. So is a trial in a multi-case shard whose result records are named by
|
||||
a display label rather than the case id, because no record can be attributed
|
||||
to it (a one-case shard's records are its case's). Only the known sum is
|
||||
shown. A train verdict's `ATTEMPT:` line reports the same as `cost=$<x> known
|
||||
+ <n> of <m> case(s) cost unknown`, a bare `cost=$<x>` only when every case
|
||||
**Meaning.** Some trials' cost was not captured, so only the known sum is
|
||||
shown. A trial is cost-unknown when it ran a PTY session whose transcript
|
||||
could not be priced (a non-hermetic or resumed launch, the native
|
||||
AskUserQuestion capture, two new transcripts for one cwd, no transcript or
|
||||
usage, or a model with no price row), a
|
||||
Codex session that completed no turn or whose model has no price row, a
|
||||
`claude -p` session that ended without a result, or an aborted Agent SDK
|
||||
session whose streamed usage could not be priced (no usage, or a model with no
|
||||
price row). A PTY or aborted Agent SDK session that does price counts its
|
||||
`streamed_cost_usd` instead. A trial is also unknown when no record and no
|
||||
session can be attributed to it, for example a multi-case shard's
|
||||
display-label record without `cost_case_id` (a one-case shard's records are
|
||||
its case's); a model-free case (`timeline-event-flow`) is a known $0. A panel
|
||||
trial (`behavior`, `judge`) is unknown whenever any of its sessions billed
|
||||
nothing, even one with a priced `streamed_cost_usd`. The rules are in
|
||||
[Failure causes and details](TESTING_INTERNALS.md#failure-causes). A train
|
||||
verdict's `ATTEMPT:` line reports the same as `cost=$<x> known + <n> of <m>
|
||||
case(s) cost unknown`, a bare `cost=$<x>` only when every case
|
||||
in the train record is priced, and no cost at all when none is.
|
||||
|
||||
**Fix.** None needed; this is informational.
|
||||
@@ -3989,7 +4004,10 @@ and the table prints `environment: <kind> — <evidence>`. A test that failed an
|
||||
whose log also names a connection error is **not** this code: the runner's own
|
||||
summary counted a failure, so it is `PREGATE_LANE_FAILED` with `cause:
|
||||
"diff"`; nor is a runner, guard or gate script whose own binary is present but
|
||||
exits 127. `proof: "launch"` is certain (the command could not start, its
|
||||
exits 127. One exception: a Playwright lane where every test failed and
|
||||
Playwright says its browsers are not installed is `missing-binary` with
|
||||
`proof: "output"` (`npx playwright install`); a partial install stays the
|
||||
diff's failure. `proof: "launch"` is certain (the command could not start, its
|
||||
binary is off PATH, the runtime or a gstack tool refused); `proof: "output"`
|
||||
means a rule matched the command's text before any test was counted, which the
|
||||
diff's own output could have written, so the line says `(inferred from output)`
|
||||
@@ -5995,7 +6013,8 @@ body that names a counter the receipt records (`passes=3` against
|
||||
against `gate.passed: 41`) with a different value. The receipt is written from
|
||||
the run; the prose was typed, so a reader of the body would learn a number the
|
||||
run did not produce. The `PROSE:` line names each field with both values. The
|
||||
`## Approvals` table `gstack-approvals` renders is never read as a claim.
|
||||
`## Approvals` table `gstack-approvals` renders, and the `Review-loop exception: …`
|
||||
line the receipt's exception requires, are never read as claims.
|
||||
|
||||
**Fix.** Correct the body to the receipt's number. When the receipt is the
|
||||
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
|
||||
|
||||
@@ -185,6 +185,10 @@ page or drop the arm. Copy the priced arms into the Arms section. An arm identit
|
||||
`<model>#<loadout>` (for example `claude-opus-5-5#no-gstack`) is priced by its model and
|
||||
gets its own pilot attempts; naming one identity twice is `EVAL_ARM_DUPLICATE`.
|
||||
|
||||
Models under test are frontier models (`claude-opus-5-5`, `gpt-6.1-sol`, at most one named
|
||||
low-cost primary); a Haiku or Sonnet arm only with a stated tier question
|
||||
(docs/TESTING_INTERNALS.md#frontier-models-under-test).
|
||||
|
||||
When the repo commits `.gstack/eval-models.json` (allow, deny, per-scope lists, families
|
||||
newest first; `docs/eval-models.md` in gstack), `arms`, `dry-run`, `pilot start` and
|
||||
`price-gate` print one `EVAL_MODEL:` line per arm. Pass `--scope smoke` for a smoke run;
|
||||
@@ -239,6 +243,7 @@ the budget the run will use. The checklist printed must be all `ok`:
|
||||
with no text is a repeat of the same failure at the same price.
|
||||
- `CHECK: reservation-fits-cap` (with `--cap <cap-usd>`) — one call, the largest row plus the
|
||||
output budget, priced, fits under the cap; a provider refuses a reservation the cap cannot hold.
|
||||
- `CHECK: frontier` — Haiku/Sonnet arms: `refused` if all (pass `--tier-question "<why>"`), `warn` if some; re-costs on frontier prices.
|
||||
- `DRY_RUN_ROW:` lines name rows missing a field; fix the rows, not the prompt.
|
||||
|
||||
`DRY_RUN_RESULT: pass` writes `dry-run.json` to `--out`; the pilot refuses to start on
|
||||
|
||||
@@ -107,6 +107,10 @@ page or drop the arm. Copy the priced arms into the Arms section. An arm identit
|
||||
`<model>#<loadout>` (for example `claude-opus-5-5#no-gstack`) is priced by its model and
|
||||
gets its own pilot attempts; naming one identity twice is `EVAL_ARM_DUPLICATE`.
|
||||
|
||||
Models under test are frontier models (`claude-opus-5-5`, `gpt-6.1-sol`, at most one named
|
||||
low-cost primary); a Haiku or Sonnet arm only with a stated tier question
|
||||
(docs/TESTING_INTERNALS.md#frontier-models-under-test).
|
||||
|
||||
When the repo commits `.gstack/eval-models.json` (allow, deny, per-scope lists, families
|
||||
newest first; `docs/eval-models.md` in gstack), `arms`, `dry-run`, `pilot start` and
|
||||
`price-gate` print one `EVAL_MODEL:` line per arm. Pass `--scope smoke` for a smoke run;
|
||||
@@ -161,6 +165,7 @@ the budget the run will use. The checklist printed must be all `ok`:
|
||||
with no text is a repeat of the same failure at the same price.
|
||||
- `CHECK: reservation-fits-cap` (with `--cap <cap-usd>`) — one call, the largest row plus the
|
||||
output budget, priced, fits under the cap; a provider refuses a reservation the cap cannot hold.
|
||||
- `CHECK: frontier` — Haiku/Sonnet arms: `refused` if all (pass `--tier-question "<why>"`), `warn` if some; re-costs on frontier prices.
|
||||
- `DRY_RUN_ROW:` lines name rows missing a field; fix the rows, not the prompt.
|
||||
|
||||
`DRY_RUN_RESULT: pass` writes `dry-run.json` to `--out`; the pilot refuses to start on
|
||||
|
||||
+21
-3
@@ -39,7 +39,7 @@ export function preregTemplate(title = 'Eval preregistration'): string {
|
||||
<!-- One line per metric: name, the pass bar, the direction, and what a miss means for the decision. -->
|
||||
|
||||
## Arms
|
||||
<!-- One line per arm: the arm identity first (\`<model>\` or \`<model>#<loadout>\`, the model in lib/pricing.ts, e.g. \`claude-opus-5-5#no-gstack\`), settings, and the per-item cost from \`gstack-eval-plan arms\`. -->
|
||||
<!-- One line per arm: the arm identity first (\`<model>\` or \`<model>#<loadout>\`, the model in lib/pricing.ts, e.g. \`claude-opus-5-5#no-gstack\`), settings, and the per-item cost from \`gstack-eval-plan arms\`. Models under test are frontier models (today \`claude-opus-5-5\`, \`gpt-6.1-sol\`) plus at most one named low-cost primary; a Haiku or Sonnet arm only with a stated tier question. -->
|
||||
|
||||
## Comparison
|
||||
<!-- \`stack: loaded\` when any arm loads gstack or another skill stack (review variants, preamble loadouts, skill diets, a model panel under a stack); otherwise \`stack: none\` and a \`why:\` line. -->
|
||||
@@ -292,10 +292,16 @@ export interface DryRunResult {
|
||||
reservation_fits_cap?: { ok: boolean; cap: number; max_input_tokens: number; per_arm: Record<string, number | null> };
|
||||
/** Present when the caller linted the arms against .gstack/eval-models.json (lib/eval-models.ts). */
|
||||
model_rules?: { ok: boolean; source: string; refused: string[] };
|
||||
/** R54: Haiku and Sonnet are not models under test; arms on them alone are refused unless the plan asks about that tier. */
|
||||
frontier: { ok: boolean; small_tier: string[]; tier_question: string | null; recost_usd: Record<string, number> };
|
||||
};
|
||||
per_item_usd: Record<string, number | null>; excluded_billing: readonly string[]; pass: boolean; stub: 'stub-model (no network, $0)';
|
||||
}
|
||||
export interface DryRunOptions { input: string; arms: string[]; fields?: string[]; outputBudget?: number; retryPrompt?: string; maxRows?: number; cap?: number; modelRules?: ModelRulesCheck }
|
||||
export interface DryRunOptions { input: string; arms: string[]; fields?: string[]; outputBudget?: number; retryPrompt?: string; maxRows?: number; cap?: number; modelRules?: ModelRulesCheck; tierQuestion?: string }
|
||||
|
||||
/** Today's frontier models under test (R54, docs/TESTING_INTERNALS.md#frontier-models-under-test); a small-tier plan is re-costed on these. */
|
||||
export const FRONTIER_MODELS = ['claude-opus-5-5', 'gpt-6.1-sol'] as const;
|
||||
export const isSmallTierModel = (model: string) => /(^|[-/])(haiku|sonnet)(-|$)/i.test(model);
|
||||
|
||||
/** Four bytes per token, the estimator every gstack admission uses. */
|
||||
export const estimateTokens = (text: string) => Math.ceil(Buffer.byteLength(text, 'utf8') / 4);
|
||||
@@ -335,9 +341,12 @@ export function dryRun(o: DryRunOptions): DryRunResult {
|
||||
checks.reservation_fits_cap = { ok: Object.values(perArm).every(v => v === null || v <= o.cap!), cap: o.cap, max_input_tokens: maxInput, per_arm: perArm };
|
||||
}
|
||||
if (o.modelRules) checks.model_rules = { ok: o.modelRules.ok, source: o.modelRules.source, refused: o.modelRules.errors.map(e => e.message) };
|
||||
const smallTier = o.arms.filter(a => isSmallTierModel(armModel(a)));
|
||||
const recost = Object.fromEntries(FRONTIER_MODELS.map(m => [m, rows * o.arms.length * estimateCostUsd({ input: avgIn, output: budget }, m)]));
|
||||
checks.frontier = { ok: smallTier.length < o.arms.length || !!o.tierQuestion?.trim(), small_tier: smallTier, tier_question: o.tierQuestion?.trim() || null, recost_usd: recost };
|
||||
return {
|
||||
schema_version: 1, input: o.input, rows, bad_rows: bad, est_input_tokens: inputTokens, est_output_tokens: rows * budget, arms: o.arms, checks, per_item_usd: perItem,
|
||||
excluded_billing: EXCLUDED_BILLING_CATEGORIES, pass: rows > 0 && bad.length === 0 && checks.price_table.ok && checks.output_budget.ok && checks.retry_prompt.ok && (checks.reservation_fits_cap?.ok ?? true) && (checks.model_rules?.ok ?? true), stub: 'stub-model (no network, $0)',
|
||||
excluded_billing: EXCLUDED_BILLING_CATEGORIES, pass: rows > 0 && bad.length === 0 && checks.price_table.ok && checks.output_budget.ok && checks.retry_prompt.ok && (checks.reservation_fits_cap?.ok ?? true) && (checks.model_rules?.ok ?? true) && checks.frontier.ok, stub: 'stub-model (no network, $0)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,6 +358,7 @@ export function renderDryRun(r: DryRunResult): string {
|
||||
`CHECK: output-budget ${r.checks.output_budget.ok ? 'ok' : 'too small'} (${r.checks.output_budget.budget} tokens; reasoning floor ${r.checks.output_budget.floor})`,
|
||||
`CHECK: retry-prompt ${r.checks.retry_prompt.ok ? `ok — ${JSON.stringify(r.checks.retry_prompt.text)}` : 'missing (pass --retry-prompt <file> with the exact text the run will send)'}`,
|
||||
...(r.checks.reservation_fits_cap ? [renderReservationCheck(r.checks.reservation_fits_cap, r.checks.output_budget.budget)] : []),
|
||||
...(r.checks.frontier.small_tier.length ? [renderFrontierCheck(r.checks.frontier, r.arms.length)] : []),
|
||||
...(r.checks.model_rules ? [`CHECK: model-rules ${r.checks.model_rules.ok ? 'ok' : `refused ${r.checks.model_rules.refused.join('; ') || 'invalid rules'}`} (source ${r.checks.model_rules.source})`] : []),
|
||||
...Object.entries(r.per_item_usd).map(([m, usd]) => `ESTIMATE: ${m} per_item=${usd === null ? 'unknown (no price)' : `$${usd.toFixed(4)}`} (estimate: excludes ${r.excluded_billing.length} billing categories, see lib/pricing.ts)`),
|
||||
`DRY_RUN_RESULT: ${r.pass ? 'pass' : 'fail'}`,
|
||||
@@ -356,6 +366,13 @@ export function renderDryRun(r: DryRunResult): string {
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function renderFrontierCheck(c: DryRunResult['checks']['frontier'], arms: number): string {
|
||||
const verdict = !c.ok ? 'refused (every arm is Haiku or Sonnet; pass --tier-question "<what the preregistration asks about that tier>" or test a frontier model)'
|
||||
: c.tier_question ? `ok (tier question: ${JSON.stringify(c.tier_question)})` : 'warn (Haiku or Sonnet arms are not models under test)';
|
||||
const recost = Object.entries(c.recost_usd).map(([m, usd]) => `${m} $${usd.toFixed(2)}`).join(', ');
|
||||
return `CHECK: frontier ${verdict} small_tier=${c.small_tier.join(',')}; all ${arms} arm(s) re-costed on frontier prices: ${recost} (docs/TESTING_INTERNALS.md#frontier-models-under-test)`;
|
||||
}
|
||||
|
||||
function renderReservationCheck(c: NonNullable<DryRunResult['checks']['reservation_fits_cap']>, budget: number): string {
|
||||
const worst = Math.max(0, ...Object.values(c.per_arm).filter((v): v is number => v !== null));
|
||||
return `CHECK: reservation-fits-cap ${c.ok ? 'ok' : 'exceeds'} ($${worst.toFixed(4)} ${c.ok ? '<=' : '>'} cap $${c.cap.toFixed(2)}) one call = ${c.max_input_tokens} in + ${budget} out tokens`;
|
||||
@@ -376,6 +393,7 @@ export interface PilotStartOptions { out: string; cap: number; n: number; arms:
|
||||
export function startPilot(o: PilotStartOptions): { record: PilotRecord; error?: { code: ResultCodeName; message: string } } {
|
||||
const dry = JSON.parse(fs.readFileSync(o.dryRun, 'utf8')) as DryRunResult;
|
||||
if (!dry.pass) return { record: null as unknown as PilotRecord, error: { code: 'EVAL_DRY_RUN_FAILED', message: `${o.dryRun} did not pass; the pilot never starts on a failed dry run` } };
|
||||
if (o.arms.length && o.arms.every(a => isSmallTierModel(armModel(a))) && !dry.checks.frontier?.tier_question) return { record: null as unknown as PilotRecord, error: { code: 'EVAL_DRY_RUN_FAILED', message: `every pilot arm is Haiku or Sonnet and ${o.dryRun} records no --tier-question (R54, docs/TESTING_INTERNALS.md#frontier-models-under-test)` } };
|
||||
const tokens = o.tokens ?? { input: dry.rows ? Math.ceil(dry.est_input_tokens / dry.rows) : DEFAULT_TOKENS.input, output: dry.checks.output_budget.budget };
|
||||
const seat = o.seatMetered ?? [];
|
||||
const stray = seat.filter(a => !o.arms.includes(a));
|
||||
|
||||
+107
-23
@@ -29,15 +29,23 @@
|
||||
* omits (bun files that fail to load), the run is red and nothing is rerun.
|
||||
* The first report is restored at the --junit path when the run ends; each
|
||||
* rerun's report is kept beside it as <name>.rerun-<n>.xml.
|
||||
* - Quarantine (lib/quarantine.ts) is a larger attempt budget, never a skip:
|
||||
* a live quarantined test is listed and ledgered on every run and never
|
||||
* counts as a pass. When every test a whole-command attempt failed is
|
||||
* quarantined and nothing else is wrong, the command may run again, up to
|
||||
* QUARANTINE_ATTEMPTS in total; the run is green only on an attempt with the
|
||||
* runner's full green verdict, so a quarantined failure never turns a red
|
||||
* attempt green. One that fails every attempt is a hard break. A quarantined
|
||||
* id missing from a run that included its file is red.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn as spawnChild, spawnSync } from 'child_process';
|
||||
import { finalAttempt, JunitError, readJunit, type JunitReport, type JunitRunner, type JunitTest } from './junit';
|
||||
import { finalAttempt, JunitError, parseTestId, readJunit, type Attempt, type JunitReport, type JunitRunner, type JunitTest } from './junit';
|
||||
import { redactFindingSpans } from './redact-engine';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
|
||||
export type LedgerKind = 'flaky-pass' | 'rerun-fail';
|
||||
export type LedgerKind = 'flaky-pass' | 'rerun-fail' | 'quarantined-pass' | 'quarantined-fail' | 'hard-break';
|
||||
|
||||
export interface LedgerLine {
|
||||
ts: string;
|
||||
@@ -64,6 +72,8 @@ export interface RunOptions {
|
||||
maxReruns: number;
|
||||
/** 'command' (default): rerun the whole command. 'test': rerun the failed test alone when the first run is proven complete. */
|
||||
rerunScope?: 'command' | 'test';
|
||||
/** Live quarantined test ids (lib/quarantine.ts liveQuarantine): up to QUARANTINE_ATTEMPTS attempts, never a skip. */
|
||||
quarantined?: ReadonlySet<string>;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Runs one attempt; resolves with its exit status and the tail of its combined output. */
|
||||
@@ -73,7 +83,7 @@ export interface RunOptions {
|
||||
|
||||
export interface TestOutcome {
|
||||
test: JunitTest;
|
||||
status: 'flaky-pass' | 'fail' | 'rerun-fail';
|
||||
status: 'flaky-pass' | 'fail' | 'rerun-fail' | 'quarantined-pass' | 'quarantined-fail' | 'hard-break';
|
||||
attempts: string[];
|
||||
reason?: string;
|
||||
}
|
||||
@@ -88,6 +98,8 @@ export interface RunResult {
|
||||
commandExit: number;
|
||||
outcomes: TestOutcome[];
|
||||
reruns: number;
|
||||
/** Outcomes of quarantined tests (listed, never counted as passes). */
|
||||
quarantined: number;
|
||||
/** Why the rerun was the whole command (the default, or why a requested targeted rerun was refused); absent for targeted reruns. */
|
||||
wholeRerun?: string;
|
||||
ledger: LedgerLine[];
|
||||
@@ -243,7 +255,7 @@ function rerunProblem(again: Attempted & { report?: JunitReport }, first: JunitR
|
||||
}
|
||||
|
||||
/** A rerun clears a test only when its command exited 0 and the test ran once and passed on its first attempt. */
|
||||
function rerunVerdict(again: Attempted & { report?: JunitReport; error?: JunitError }, t: JunitTest, problem?: string): { status: 'flaky-pass' | 'rerun-fail'; attempts: string[]; reason?: string } {
|
||||
function rerunVerdict(again: Attempted & { report?: JunitReport; error?: JunitError }, t: JunitTest, problem?: string): { status: 'flaky-pass' | 'rerun-fail'; attempts: Attempt[]; reason?: string } {
|
||||
const match = again.report?.tests.find(x => x.id === t.id);
|
||||
if (!match) return { status: 'rerun-fail', attempts: [...t.attempts, 'fail'], reason: again.error ? `rerun: ${again.error.message}` : 'rerun: the report does not contain this test' };
|
||||
const attempts = [...t.attempts, ...match.attempts];
|
||||
@@ -254,11 +266,47 @@ function rerunVerdict(again: Attempted & { report?: JunitReport; error?: JunitEr
|
||||
return { status: 'flaky-pass', attempts };
|
||||
}
|
||||
|
||||
export const QUARANTINE_ATTEMPTS = 3;
|
||||
|
||||
const PASSING: ReadonlySet<TestOutcome['status']> = new Set(['flaky-pass', 'quarantined-pass']);
|
||||
|
||||
type Ran = Attempted & { report?: JunitReport; error?: JunitError };
|
||||
|
||||
/** The runner's own full green verdict for a whole-command attempt: exit 0, no failed or missing test, nothing counted outside the report. */
|
||||
function fullGreen(again: Ran, first: JunitReport): boolean {
|
||||
return !!again.report && !again.signal && again.exit === 0 && !rerunProblem(again, first, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a red whole-command attempt may be followed by another: only within
|
||||
* the quarantine budget, and only when every test it failed is a live
|
||||
* quarantined test that ran once and nothing else is wrong (a kill, a missing
|
||||
* report, failures outside the report, a first-run test missing).
|
||||
*/
|
||||
function mayRunAgain(again: Ran, first: JunitReport, quarantined: ReadonlySet<string>, attemptsSoFar: number): boolean {
|
||||
if (attemptsSoFar >= QUARANTINE_ATTEMPTS || again.signal || !again.report) return false;
|
||||
const failedNow = again.report.tests.filter(x => finalAttempt(x) === 'fail');
|
||||
if (!failedNow.length || !failedNow.every(x => quarantined.has(x.id) && x.executions === 1 && x.attempts.length === 1)) return false;
|
||||
if (unreportedFailures(first.runner, again.output, failedNow.length)) return false;
|
||||
const seen = new Set(again.report.tests.map(x => x.id));
|
||||
return first.tests.every(x => seen.has(x.id));
|
||||
}
|
||||
|
||||
/** A test's attempts over the first run and the given whole-command reruns; a rerun that did not report it counts as a failure. */
|
||||
const history = (t: JunitTest, runs: Ran[]): Attempt[] =>
|
||||
[...t.attempts, ...runs.flatMap(r => r.report?.tests.find(x => x.id === t.id)?.attempts ?? ['fail' as const])];
|
||||
|
||||
/** A quarantined test passes only when it never failed or an attempt with the runner's full verdict cleared it. */
|
||||
const quarantinedStatus = (attempts: string[], cleared: boolean): 'quarantined-pass' | 'quarantined-fail' | 'hard-break' =>
|
||||
cleared || !attempts.includes('fail') ? 'quarantined-pass'
|
||||
: attempts.length >= QUARANTINE_ATTEMPTS && attempts.every(a => a === 'fail') ? 'hard-break' : 'quarantined-fail';
|
||||
|
||||
export async function runFlaky(opts: RunOptions): Promise<RunResult> {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const env = { ...(opts.env ?? process.env), PLAYWRIGHT_JUNIT_INCLUDE_RETRIES: '1' };
|
||||
const now = opts.now ?? (() => new Date());
|
||||
const empty = { tests: 0, outcomes: [], reruns: 0, ledger: [] };
|
||||
const quarantined = opts.quarantined ?? new Set<string>();
|
||||
const empty = { tests: 0, outcomes: [], reruns: 0, quarantined: 0, ledger: [] };
|
||||
const first = await attempt(opts, opts.command, cwd, env);
|
||||
if (first.signal) return { exitCode: 3, code: 'FLAKY_COMMAND_KILLED', message: `the command was killed by ${first.signal}`, commandExit: first.exit, ...empty };
|
||||
if (!first.report) return { exitCode: 3, code: first.error!.code, message: first.error!.message, commandExit: first.exit, ...empty };
|
||||
@@ -282,15 +330,39 @@ export async function runFlaky(opts: RunOptions): Promise<RunResult> {
|
||||
const prov = provenance(cwd, env);
|
||||
const line = (t: JunitTest, kind: LedgerKind, by: LedgerLine['rerun_by'], attempts: string[], scope?: LedgerLine['rerun_scope']): LedgerLine =>
|
||||
({ ts: now().toISOString(), runner: 'junit', kind, id: safeText(t.id), file: safeText(t.file), rerun_by: by, ...(scope ? { rerun_scope: scope } : {}), attempts, ...prov, excerpt: safeExcerpt(t.excerpt) });
|
||||
const ledger: LedgerLine[] = runnerFlaky.map(t => line(t, 'flaky-pass', 'runner', t.attempts));
|
||||
const outcomes: TestOutcome[] = runnerFlaky.map(t => ({ test: t, status: 'flaky-pass' as const, attempts: t.attempts }));
|
||||
const isQ = (t: JunitTest) => quarantined.has(t.id);
|
||||
const outcomes: TestOutcome[] = [];
|
||||
const ledger: LedgerLine[] = [];
|
||||
for (const t of runnerFlaky.filter(x => !isQ(x))) {
|
||||
outcomes.push({ test: t, status: 'flaky-pass', attempts: t.attempts });
|
||||
ledger.push(line(t, 'flaky-pass', 'runner', t.attempts));
|
||||
}
|
||||
const settleQ = (t: JunitTest, attempts: string[], cleared: boolean, reason?: string, scope?: LedgerLine['rerun_scope']) => {
|
||||
const status = quarantinedStatus(attempts, cleared);
|
||||
const why = status === 'hard-break' ? `failed all ${attempts.length} attempts: a hard break, not a flake` : status === 'quarantined-fail' ? reason : undefined;
|
||||
outcomes.push({ test: t, status, attempts, ...(why ? { reason: why } : {}) });
|
||||
ledger.push(line(t, status, 'gstack-flaky', attempts, scope));
|
||||
};
|
||||
const settle = (t: JunitTest, attempts: string[], reason: string) => isQ(t) ? settleQ(t, attempts, false, reason) : outcomes.push({ test: t, status: 'fail', attempts, reason });
|
||||
const present = new Set(report.tests.map(t => t.id));
|
||||
const included = new Set(report.tests.map(t => `${t.file}::${t.project}`));
|
||||
for (const id of quarantined) {
|
||||
const p = parseTestId(id);
|
||||
if (!p || present.has(id) || !included.has(`${p.file}::${p.project}`)) continue;
|
||||
const t: JunitTest = { id, file: p.file, project: p.project, path: p.title.split(' > '), attempts: [], executions: 0, excerpt: [] };
|
||||
outcomes.push({ test: t, status: 'quarantined-fail', attempts: [], reason: 'quarantined, and missing from a run that included its file' });
|
||||
ledger.push(line(t, 'quarantined-fail', 'gstack-flaky', []));
|
||||
}
|
||||
const others = failed.filter(t => !isQ(t)).length;
|
||||
const eligible: JunitTest[] = [];
|
||||
for (const t of failed) {
|
||||
if (t.executions > 1) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `it ran ${t.executions} times in this report and one failed; not rerun` });
|
||||
else if (t.attempts.length > 1) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `the runner already retried (${t.attempts.length} attempts)` });
|
||||
else if (failed.length > opts.maxReruns) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `${failed.length} tests failed, more than --max-reruns ${opts.maxReruns}; not rerun` });
|
||||
const why = t.executions > 1 ? `it ran ${t.executions} times in this report and one failed; not rerun`
|
||||
: t.attempts.length > 1 ? `the runner already retried (${t.attempts.length} attempts)`
|
||||
: !isQ(t) && others > opts.maxReruns ? `${others} tests failed, more than --max-reruns ${opts.maxReruns}; not rerun` : undefined;
|
||||
if (why) settle(t, t.attempts, why);
|
||||
else eligible.push(t);
|
||||
}
|
||||
const passedQ = report.tests.filter(t => isQ(t) && finalAttempt(t) !== 'fail' && t.attempts.some(a => a !== 'skip'));
|
||||
const wholeRerun = !eligible.length ? undefined
|
||||
: opts.rerunScope !== 'test' ? 'the default; --rerun-scope test reruns the failed test alone'
|
||||
: incompleteRun(report.runner, first.output, first.exit, opts.command, runnerFlaky.length) ?? undefined;
|
||||
@@ -304,30 +376,42 @@ export async function runFlaky(opts: RunOptions): Promise<RunResult> {
|
||||
return again;
|
||||
};
|
||||
const record = (t: JunitTest, v: ReturnType<typeof rerunVerdict>, scope: 'test' | 'command') => {
|
||||
if (isQ(t)) return settleQ(t, v.attempts, v.status === 'flaky-pass', v.reason, scope);
|
||||
outcomes.push({ test: t, status: v.status, attempts: v.attempts, ...(v.reason ? { reason: v.reason } : {}) });
|
||||
ledger.push(line(t, v.status, 'gstack-flaky', v.attempts, scope));
|
||||
};
|
||||
const runs: Ran[] = [];
|
||||
try {
|
||||
if (wholeRerun && outcomes.some(o => o.status === 'fail')) {
|
||||
for (const t of eligible) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `not rerun: the run is already red, and a rerun would repeat the whole command (${wholeRerun})` });
|
||||
if (wholeRerun && outcomes.some(o => !PASSING.has(o.status))) {
|
||||
for (const t of eligible) settle(t, t.attempts, `not rerun: the run is already red, and a rerun would repeat the whole command (${wholeRerun})`);
|
||||
} else if (wholeRerun) {
|
||||
const again = await rerun(opts.command);
|
||||
const problem = rerunProblem(again, report, true);
|
||||
for (const t of eligible) record(t, rerunVerdict(again, t, problem), 'command');
|
||||
do {
|
||||
runs.push(await rerun(opts.command));
|
||||
} while (!fullGreen(runs.at(-1)!, report) && mayRunAgain(runs.at(-1)!, report, quarantined, 1 + runs.length));
|
||||
const decider = runs.at(-1)!;
|
||||
const problem = rerunProblem(decider, report, true);
|
||||
for (const t of eligible) record(t, rerunVerdict(decider, { ...t, attempts: history(t, runs.slice(0, -1)) }, problem), 'command');
|
||||
} else if (eligible.length) {
|
||||
let interrupted = false;
|
||||
for (const t of eligible) {
|
||||
if (interrupted) { outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: 'not rerun: an earlier rerun was killed' }); continue; }
|
||||
const again = await rerun([...opts.command, ...rerunSelection(report.runner as 'playwright' | 'bun', t)]);
|
||||
interrupted = !!again.signal;
|
||||
record(t, rerunVerdict(again, t, rerunProblem(again, report, false)), 'test');
|
||||
if (interrupted) { settle(t, t.attempts, 'not rerun: an earlier rerun was killed'); continue; }
|
||||
let v: ReturnType<typeof rerunVerdict> = { status: 'rerun-fail', attempts: t.attempts };
|
||||
for (let budget = isQ(t) ? QUARANTINE_ATTEMPTS - t.attempts.length : 1; budget > 0 && v.status !== 'flaky-pass' && !interrupted; budget--) {
|
||||
const again = await rerun([...opts.command, ...rerunSelection(report.runner as 'playwright' | 'bun', t)]);
|
||||
interrupted = !!again.signal;
|
||||
v = rerunVerdict(again, { ...t, attempts: v.attempts }, rerunProblem(again, report, false));
|
||||
}
|
||||
record(t, v, 'test');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (reruns) fs.writeFileSync(junit, firstXml);
|
||||
}
|
||||
const red = outcomes.some(o => o.status !== 'flaky-pass');
|
||||
return { exitCode: red ? 1 : 0, tests: report.tests.length, commandExit: first.exit, outcomes, reruns, ...(wholeRerun && reruns ? { wholeRerun } : {}), ledger };
|
||||
const green = runs.length ? fullGreen(runs.at(-1)!, report) : reruns > 0 || first.exit === 0;
|
||||
for (const t of passedQ) settleQ(t, history(t, runs), green, 'the attempt that decided the run was not a full green run', runs.length ? 'command' : undefined);
|
||||
const red = !green || outcomes.some(o => !PASSING.has(o.status));
|
||||
const q = outcomes.filter(o => o.status === 'quarantined-pass' || o.status === 'quarantined-fail' || o.status === 'hard-break').length;
|
||||
return { exitCode: red ? 1 : 0, tests: report.tests.length, commandExit: first.exit, outcomes, reruns, quarantined: q, ...(wholeRerun && reruns ? { wholeRerun } : {}), ledger };
|
||||
}
|
||||
|
||||
export function ledgerPath(flag: string | undefined, env: NodeJS.ProcessEnv = process.env): string {
|
||||
@@ -352,9 +436,9 @@ export function renderResult(r: RunResult, ledger: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const o of r.outcomes) out.push(`FLAKY: ${o.status} ${safeText(o.test.id)} attempts=${o.attempts.join(',')}${o.reason ? ` (${o.reason})` : ''}`);
|
||||
const flaky = r.outcomes.filter(o => o.status === 'flaky-pass').length;
|
||||
const failed = r.outcomes.length - flaky;
|
||||
const failed = r.outcomes.filter(o => !PASSING.has(o.status)).length;
|
||||
if (r.wholeRerun) out.push(`FLAKY_RERUN: scope=command (${r.wholeRerun})`);
|
||||
out.push(`FLAKY_RESULT: tests=${r.tests} failed=${failed} flaky=${flaky} reruns=${r.reruns} command_exit=${r.commandExit} verdict=${r.exitCode === 0 ? 'pass' : 'fail'} ledger=${r.ledger.length ? ledger : 'none'}`);
|
||||
out.push(`FLAKY_RESULT: tests=${r.tests} failed=${failed} flaky=${flaky} quarantined=${r.quarantined} reruns=${r.reruns} command_exit=${r.commandExit} verdict=${r.exitCode === 0 ? 'pass' : 'fail'} ledger=${r.ledger.length ? ledger : 'none'}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,14 @@ function excerptOf(node: XmlNode | undefined): string[] {
|
||||
|
||||
export const testId = (file: string, project: string, path: string[]) => `${file}::${project}::${path.join(' > ')}`;
|
||||
|
||||
/** The parts of a test id, or null when it is not `<file>::<project>::<title>`. */
|
||||
export function parseTestId(id: string): { file: string; project: string; title: string } | null {
|
||||
const i = id.indexOf('::');
|
||||
const j = i < 0 ? -1 : id.indexOf('::', i + 2);
|
||||
if (i <= 0 || j < 0 || j + 2 >= id.length) return null;
|
||||
return { file: id.slice(0, i), project: id.slice(i + 2, j), title: id.slice(j + 2) };
|
||||
}
|
||||
|
||||
function readCase(tc: XmlNode, runner: JunitRunner): Omit<JunitTest, 'id' | 'executions'> & { hidden: boolean } {
|
||||
const suite = tc.parent!;
|
||||
let file: string, project = '', path: string[];
|
||||
|
||||
+81
-15
@@ -27,6 +27,7 @@
|
||||
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
|
||||
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { baseInputs, sha256 } from '../context';
|
||||
@@ -43,17 +44,21 @@ import { verifyFromRun } from '../../run-evidence';
|
||||
import { matchesAny } from '../../ship-policy';
|
||||
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
|
||||
|
||||
/** The test command with `{files}`; detected from the repo when the config does not name one. */
|
||||
/** The runner a `.py`-only selection gets; it needs the pytest module importable by python3 (`pytestMissing`). */
|
||||
const PYTEST = 'python3 -m pytest {files}';
|
||||
|
||||
/** The test command with `{files}`; detected from the repo when the config does not name one. The repo's bun, vitest or jest claims JS/TS files only. */
|
||||
export function detectRunner(ctx: Pick<PregateContext, 'config' | 'repoRoot'>, files: readonly string[]): string | null {
|
||||
if (ctx.config.runner) return ctx.config.runner;
|
||||
const has = (f: string) => fs.existsSync(path.join(ctx.repoRoot, f));
|
||||
let pkg: { devDependencies?: Record<string, string>; dependencies?: Record<string, string>; scripts?: Record<string, string> } = {};
|
||||
try { pkg = JSON.parse(fs.readFileSync(path.join(ctx.repoRoot, 'package.json'), 'utf8')); } catch { /* no package.json */ }
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
if (has('bunfig.toml') || has('bun.lock') || has('bun.lockb')) return 'bun test {files}';
|
||||
if (deps.vitest) return 'npx vitest run {files}';
|
||||
if (deps.jest) return 'npx jest {files}';
|
||||
if (files.every(f => /\.py$/.test(f)) && files.length) return 'python3 -m pytest {files}';
|
||||
const jsts = files.every(f => /\.[cm]?[jt]sx?$/.test(f));
|
||||
if (jsts && (has('bunfig.toml') || has('bun.lock') || has('bun.lockb'))) return 'bun test {files}';
|
||||
if (jsts && deps.vitest) return 'npx vitest run {files}';
|
||||
if (jsts && deps.jest) return 'npx jest {files}';
|
||||
if (files.every(f => /\.py$/.test(f)) && files.length) return PYTEST;
|
||||
if (files.every(f => /\.[cm]?js$/.test(f)) && files.length) return 'node --test {files}';
|
||||
if (files.every(f => /\.[cm]?[jt]sx?$/.test(f)) && files.length && Bun.which('bun')) return 'bun test {files}';
|
||||
return null;
|
||||
@@ -113,6 +118,18 @@ export function dartRunner(repoRoot: string, root: string, env: NodeJS.ProcessEn
|
||||
return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool `python3 -m pytest` lacks: `python3` off the PATH of `env`, or `pytest` when that python3, run from the repo root as the
|
||||
* runner is, cannot import it (a `pytest` script on PATH may belong to another interpreter); null when both are there, or when the
|
||||
* probe itself times out or dies, so the run decides.
|
||||
*/
|
||||
function pytestMissing(repoRoot: string, env: NodeJS.ProcessEnv): string | null {
|
||||
const python = Bun.which('python3', { PATH: env.PATH ?? '' });
|
||||
if (!python) return 'python3';
|
||||
const r = spawnSync(python, ['-c', 'import importlib.util, sys; sys.exit(3 if importlib.util.find_spec("pytest") is None else 0)'], { cwd: repoRoot, env, stdio: 'ignore', timeout: 10_000 });
|
||||
return r.status === 3 ? 'pytest' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `cargo test -p <name>` for a crate (cargo runs a crate's tests, never a file list), or the missing tool when cargo is
|
||||
* not on PATH. The name comes from the repo's Cargo.toml and reaches a shell and the `{files}` expansion: a name outside
|
||||
@@ -163,6 +180,23 @@ function packageRunner(repoRoot: string, dir: string, env: NodeJS.ProcessEnv): {
|
||||
return { runner: `${PM_EXEC[pm]} ${tool} {files}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a JS/TS test imports a value from Playwright's runner in a statement that starts a line: `import … from '@playwright/test'`
|
||||
* (one line or several), a bare import, or `require`. A type-only import (`import type …`, or every specifier `type X`) and a string or
|
||||
* fixture body that mentions the import mid-line do not count.
|
||||
*/
|
||||
function importsPlaywright(text: string): boolean {
|
||||
const code = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
|
||||
if (/^[\t ]*(?:import[\t ]*|(?:const|let|var)\b[^=\n]*=[\t ]*require\([\t ]*)['"]@playwright\/test['"]/m.test(code)) return true;
|
||||
for (const m of code.matchAll(/^[\t ]*import(?=[\s{*])([^'"`;]*?)\bfrom[\t ]*['"]@playwright\/test['"]/gm)) {
|
||||
const spec = m[1]!.trim();
|
||||
if (!/^type(?=[\s{*])/.test(spec) && spec.replace(/[{}]/g, '').split(',').some(s => s.trim() && !/^type\s/.test(s.trim()))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Playwright's own text when its browsers are not installed on this machine. */
|
||||
const PLAYWRIGHT_NO_BROWSERS = /^.*(?:Executable doesn't exist at|download new browsers).*$/m;
|
||||
|
||||
/** The run plan: the groups that can run, the files no runner claims, tools missing from PATH, and `.dart` files with no pubspec.yaml above. */
|
||||
export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: string[]; noPubspec: string[] }
|
||||
|
||||
@@ -171,8 +205,11 @@ export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: str
|
||||
* Dart tests run from their own package root (a sub-package's imports resolve
|
||||
* against its own pubspec); Rust tests run per crate as `cargo test -p <name>`
|
||||
* from the crate's workspace root; JS/TS tests owned by a nested package that
|
||||
* declares vitest or jest run from that package; the rest go to the detected
|
||||
* runner from the repo root. Files no runner claims (a tool missing from PATH,
|
||||
* declares vitest or jest run from that package; a JS/TS test that imports
|
||||
* `@playwright/test` runs through `playwright test` from its nearest package;
|
||||
* the rest go to the detected runner from the repo root, split by language
|
||||
* when no one runner fits them all (`.mjs` + `.py` runs both), and pytest only
|
||||
* when python3 can import it. Files no runner claims (a tool missing from PATH,
|
||||
* a `.dart` file outside any package, a `.rs` file outside any crate or in a
|
||||
* crate whose name Cargo would reject, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass.
|
||||
*/
|
||||
@@ -182,6 +219,7 @@ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan
|
||||
const dart = new Map<string, string[]>();
|
||||
const cargo = new Map<string, { name: string; cwd: string; files: string[] }>();
|
||||
const packages = new Map<string, string[]>();
|
||||
const playwright = new Map<string, string[]>();
|
||||
const seen = new Map<string, string | null>();
|
||||
const seenPackage = new Map<string, string | null>();
|
||||
let model: CargoModel | null = null;
|
||||
@@ -204,6 +242,7 @@ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan
|
||||
continue;
|
||||
}
|
||||
const pkg = /\.[cm]?[jt]sx?$/.test(f) ? nearestRoot(ctx.repoRoot, f, 'package.json', seenPackage) : null;
|
||||
if (pkg !== null && importsPlaywright(readRepoFile(ctx.repoRoot, f) ?? '')) { add(playwright, pkg, under(pkg, f)); continue; }
|
||||
if (pkg) {
|
||||
if (!pkgRunners.has(pkg)) pkgRunners.set(pkg, packageRunner(ctx.repoRoot, pkg, ctx.env));
|
||||
const r = pkgRunners.get(pkg);
|
||||
@@ -225,12 +264,24 @@ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan
|
||||
if (r) missing.add(r.missing);
|
||||
unclaimed.push(...c.files.map(f => (c.cwd ? `${c.cwd}/${f}` : f)));
|
||||
}
|
||||
if (rest.length) {
|
||||
const runner = detectRunner(ctx, rest);
|
||||
if (runner) groups.push({ runner, cwd: '', files: rest });
|
||||
else unclaimed.push(...rest);
|
||||
const language = (f: string) => (/\.py$/.test(f) ? 'py' : /\.[cm]?js$/.test(f) ? 'js' : /\.[cm]?[jt]sx?$/.test(f) ? 'ts' : f);
|
||||
const parts = !rest.length ? [] : detectRunner(ctx, rest) ? [rest] : [...Map.groupBy(rest, language).values()];
|
||||
const byRunner = new Map<string, string[]>();
|
||||
for (const part of parts) {
|
||||
const runner = detectRunner(ctx, part);
|
||||
const lacking = runner === PYTEST ? pytestMissing(ctx.repoRoot, ctx.env) : null;
|
||||
if (lacking) missing.add(lacking);
|
||||
if (runner && !lacking) for (const f of part) add(byRunner, runner, f);
|
||||
else unclaimed.push(...part);
|
||||
}
|
||||
for (const [runner, list] of byRunner) groups.push({ runner, cwd: '', files: list });
|
||||
for (const [root, list] of [...packages].sort(([a], [b]) => a.localeCompare(b))) groups.push({ runner: (pkgRunners.get(root) as { runner: string }).runner, cwd: root, files: list });
|
||||
for (const [root, list] of [...playwright].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const pm = packageManager(ctx.repoRoot, root);
|
||||
const bin = pm === 'npm' ? 'npx' : pm;
|
||||
if (onPath(bin, ctx.env)) groups.push({ runner: `${PM_EXEC[pm]} playwright test --reporter=list {files}`, cwd: root, files: list });
|
||||
else { missing.add(bin); unclaimed.push(...list.map(f => (root ? `${root}/${f}` : f))); }
|
||||
}
|
||||
return { groups, unclaimed: [...unclaimed, ...noPubspec], missing: [...missing].sort(), noPubspec };
|
||||
}
|
||||
|
||||
@@ -269,11 +320,20 @@ function parseCargo(output: string): RunnerSummary | null {
|
||||
return { ran: lines.reduce((n, m) => n + Number(m[1]), 0) + failed, failed };
|
||||
}
|
||||
|
||||
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test. A bats, dart/flutter or cargo runner's summary is tried first (cargo's only there: its `N passed` lines would misread as another runner's); for any other runner the bats and dart formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
|
||||
/** playwright test: the last `N failed`, `N interrupted`, `N flaky`, `N passed` and `N skipped` lines (its closing summary, with test names between them; a test's own output earlier does not count); skipped tests did not run. */
|
||||
function parsePlaywright(output: string): RunnerSummary | null {
|
||||
if (!/^\s*\d+ (?:passed|failed|flaky|interrupted|skipped)\b/m.test(output)) return null;
|
||||
const count = (word: string) => Number([...output.matchAll(new RegExp(`^\\s*(\\d+) ${word}\\b`, 'gm'))].pop()?.[1] ?? 0);
|
||||
const failed = count('failed') + count('interrupted');
|
||||
return { ran: count('passed') + count('flaky') + failed, failed };
|
||||
}
|
||||
|
||||
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test, playwright test. A bats, dart/flutter, playwright or cargo runner's summary is tried first (cargo's only there: its `N passed` lines would misread as another runner's); for any other runner the bats and dart formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
|
||||
export function parseSummary(raw: string, runner = ''): RunnerSummary {
|
||||
const output = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n');
|
||||
if (/(?<![\w-])cargo\s+test(?![\w-])/.test(runner)) return parseCargo(output) ?? { ran: null, failed: null };
|
||||
if (/(?<![\w-])bats(?![\w-])/.test(runner)) { const b = parseBats(output); if (b) return b; }
|
||||
if (/(?<![\w-])playwright\s+test(?![\w-])/.test(runner)) { const p = parsePlaywright(output); if (p) return p; }
|
||||
const dartRun = /(?<![\w-])(?:dart|flutter)(?![\w-])/.test(runner);
|
||||
if (dartRun) { const d = parseDart(output); if (d) return d; }
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -287,19 +347,22 @@ export function parseSummary(raw: string, runner = ''): RunnerSummary {
|
||||
return (dartRun ? null : parseDart(output)) ?? parseBats(output) ?? { ran: null, failed: null };
|
||||
}
|
||||
|
||||
interface LocalRun { exit: number | null; outcome: Supervised['outcome']; signal: string | null; observedStart: boolean; ran: number | null; failed: number | null; tail: string[]; runner: string; command: string; files: string[]; lastFile: string | null; ms: number; error?: string }
|
||||
interface LocalRun { exit: number | null; outcome: Supervised['outcome']; signal: string | null; observedStart: boolean; ran: number | null; failed: number | null; tail: string[]; runner: string; command: string; files: string[]; lastFile: string | null; ms: number; error?: string; noBrowsers?: string }
|
||||
|
||||
const quote = (f: string) => `'${f.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, g: RunGroup, files: string[], log: string, counter: Counter): Promise<LocalRun> {
|
||||
const joined = files.map(quote).join(' ');
|
||||
// playwright test reads each file argument as a case-insensitive regex: escape it and anchor it to a path segment, so `a[1].spec.ts` runs that file, not `a1.spec.ts`.
|
||||
const args = /(?<![\w-])playwright\s+test(?![\w-])/.test(g.runner) ? files.map(f => `(?:^|/)${f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) : files;
|
||||
const joined = args.map(quote).join(' ');
|
||||
const run = g.runner.replace('{files}', () => joined);
|
||||
const command = g.cwd ? `(cd ${quote(g.cwd)} && ${run})` : run;
|
||||
const shown = g.cwd ? files.map(f => `${g.cwd}/${f}`) : files;
|
||||
const r = await supervise({ command: run, cwd: path.join(ctx.repoRoot, g.cwd), env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `lanes (${shown.length === 1 ? shown[0] : `${shown.length} files`})`, files, adapter: adapterFor(g.runner), counter, progress: ctx.progress });
|
||||
const output = readRange(log, r.start, r.end);
|
||||
const summary = parseSummary(output, g.runner);
|
||||
return { exit: r.exit, outcome: r.outcome, signal: r.signal, observedStart: r.observedStart, ...summary, tail: output.trim().split('\n').slice(-25), runner: g.runner, command, files: shown, lastFile: r.lastFile && g.cwd ? `${g.cwd}/${r.lastFile}` : r.lastFile, ms: r.ms, ...(r.error ? { error: r.error } : {}) };
|
||||
const noBrowsers = /(?<![\w-])playwright\s+test(?![\w-])/.test(g.runner) ? PLAYWRIGHT_NO_BROWSERS.exec(output)?.[0].trim().slice(0, 200) : undefined;
|
||||
return { ...(noBrowsers ? { noBrowsers } : {}), exit: r.exit, outcome: r.outcome, signal: r.signal, observedStart: r.observedStart, ...summary, tail: output.trim().split('\n').slice(-25), runner: g.runner, command, files: shown, lastFile: r.lastFile && g.cwd ? `${g.cwd}/${r.lastFile}` : r.lastFile, ms: r.ms, ...(r.error ? { error: r.error } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -496,6 +559,9 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul
|
||||
// only when the runner's binary is off PATH.
|
||||
const environment = r.ran === null && r.bad && !r.bad.observedStart ? classifyCommandEnvironment(r.bad.runner, r.tail.join('\n'), r.exit, runtime.env) : null;
|
||||
if (environment) return environmentResult(withRun, environment, `the runner exited ${r.exit} before any test was counted (${total} files): ${environment.evidence}`);
|
||||
// Playwright counts a test whose browser is not installed as failed; when every test of every failed run failed and Playwright says so, the machine lacks the browsers.
|
||||
const failedRuns = r.runs.filter(x => x.exit !== 0);
|
||||
if (failedRuns.every(x => x.noBrowsers && x.failed !== null && x.failed === x.ran)) return environmentResult(withRun, { kind: 'missing-binary', evidence: `Playwright browsers not installed (npx playwright install): ${failedRuns[0]!.noBrowsers}`, proof: 'output' }, `every Playwright test failed to launch its browser (${r.failed} of ${r.ran}; ${total} files)`);
|
||||
return { ...withRun, status: 'fail', cause: 'diff', code: 'PREGATE_LANE_FAILED', detail: `local lane exited ${r.exit} (${r.failed ?? '?'} failed of ${r.ran ?? '?'} ran; ${total} files; pipefail)`, fix: `read ${r.log}, fix the in-branch failure, rerun gstack-pregate --stage tests` };
|
||||
}
|
||||
if (r.ran === 0) return { ...withRun, status: 'fail', cause: 'diff', code: 'PREGATE_LANE_FAILED', detail: `ZERO-RUN: the runner reported 0 tests${r.zero ? ` in ${r.zero}` : ''} (${total} selected file(s))`, fix: 'a bad selector, a missing file or a skipped describe; exit 0 is not a pass' };
|
||||
|
||||
@@ -69,7 +69,12 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) :
|
||||
* `/retry.ts.snap`, or `lib/install.ts` for `install.sh`, is not the module (GST-820): a bare stem such as `install` or `retry` is an
|
||||
* English word in half the test tree, and a one-character old value (`2`)
|
||||
* sits in every test, so that value needs the constant's name (a wrapper
|
||||
* version 2 → 3 flagged 575 consumers through the stem alone).
|
||||
* version 2 → 3 flagged 575 consumers through the stem alone), and counts only
|
||||
* in a statement that names it: on the name's line, or on a line that
|
||||
* continues it (the line before ends open, `(` `,` `.` `=`, or the line
|
||||
* starts with `.` `)` `]`), comments excluded (#3492: `$2`, "from revision 2",
|
||||
* `export default 2`). A call-site name (`attempts`, `limit`) is an ordinary
|
||||
* word, so for it the file must also name the module (gstack-evals#97).
|
||||
*
|
||||
* A version-history row (`[45, "<digest>"]`, the old value first in a
|
||||
* bracketed row) is skipped when the same table, a run of consecutive
|
||||
@@ -94,12 +99,23 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
|
||||
const moduleRe = new RegExp(`(?:^|[/\\\\])${escape(stem)}(?=[^\\w.-]|$|\\.${dts}(?:${exts})(?![\\w.-])|\\.(?!\\w))|(?<![\\w-])${escape(base)}(?![\\w-]|\\.\\w)`, 'm');
|
||||
const nameRe = new RegExp(`\\b${escape(change.name)}\\b`);
|
||||
const nameRequired = value.length < 2;
|
||||
const callSite = !/^[A-Z]/.test(change.name);
|
||||
const comment = (l: string) => /^\s*(?:\/\/|#|\*|\/\*|<!--)/.test(l);
|
||||
const continues = (prev: string, cur: string) => /[([{,.=+\-*/&|?:\\]\s*$/.test(prev) || /^\s*[.)\]}?:&|+]/.test(cur);
|
||||
const hits: string[] = [];
|
||||
for (const rel of testFiles) {
|
||||
let text: string;
|
||||
try { text = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
|
||||
if (!(nameRe.test(text) || (!nameRequired && moduleRe.test(text)))) continue;
|
||||
if (nameRequired ? !(nameRe.test(text) && (!callSite || moduleRe.test(text))) : !(nameRe.test(text) || moduleRe.test(text))) continue;
|
||||
const lines = text.split('\n');
|
||||
const named = (i: number) => {
|
||||
if (comment(lines[i]!)) return false;
|
||||
for (let j = i; j >= 0 && i - j < 4; j--) {
|
||||
if (j < i && (comment(lines[j]!) || !continues(lines[j]!, lines[j + 1]!))) return false;
|
||||
if (nameRe.test(lines[j]!)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const table: number[] = [];
|
||||
const tableHasNew: boolean[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
@@ -115,7 +131,7 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
|
||||
if (extended === undefined) { const base = baseText?.(rel); extended = base === null || (base !== undefined && !newRowRe.test(base)); }
|
||||
return extended;
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
|
||||
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && (!nameRequired || oldRowRe.test(lines[i]!) || named(i)) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
@@ -79,8 +79,7 @@ export function draftConfig(repoRoot: string): InitDraft {
|
||||
const runner = detectRunner({ repoRoot, config: DEFAULT_CONFIG }, list);
|
||||
return runner ? `${lang} tests detected at run time: ${runner}` : `no runner detected for ${list.length} ${lang} test file(s); set runner (with {files})`;
|
||||
});
|
||||
const mixed = [...groups.keys()].filter(l => l !== 'dart' && l !== 'rust').length > 1 ? '; a selection mixing these languages needs runner set' : '';
|
||||
const runnerNote = notes.length ? notes.join('; ') + mixed : 'no test files found';
|
||||
const runnerNote = notes.length ? notes.join('; ') : 'no test files found';
|
||||
|
||||
const selectionTodo = discoverLanes(repoRoot).filter(l => l.runsTests && l.matrixExpression && l.testFiles.length === 0).map(l => l.id);
|
||||
const config = { $schema: PREGATE_CONFIG_SCHEMA, runner: null, dependencies, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] } };
|
||||
|
||||
@@ -149,3 +149,66 @@ export function estimateCostUsd(
|
||||
const outputCost = tokens.output * row.output_per_mtok / 1_000_000;
|
||||
return +(inputCost + cachedCost + outputCost).toFixed(6);
|
||||
}
|
||||
|
||||
/** Token classes of one Anthropic Messages usage block, as Claude Code's stream-json and session transcripts carry it. */
|
||||
export interface AnthropicUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };
|
||||
}
|
||||
|
||||
/** Anthropic bills a prompt-cache write at 1.25x input for the 5-minute TTL and 2x for the 1-hour TTL. */
|
||||
export const CACHE_WRITE_MULTIPLIER = { ephemeral_5m: 1.25, ephemeral_1h: 2 } as const;
|
||||
|
||||
/**
|
||||
* Cost of one usage block at its model's row, prompt-cache writes included
|
||||
* (unlike estimateCostUsd). Undefined when the model has no price row: an
|
||||
* unpriced model's usage is unknown cost, never zero.
|
||||
*/
|
||||
export function anthropicUsageCostUsd(usage: AnthropicUsage, model: string | undefined): number | undefined {
|
||||
const row = pricingRow(model);
|
||||
if (!row) return undefined;
|
||||
const count = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
const oneHour = count(usage.cache_creation?.ephemeral_1h_input_tokens);
|
||||
const fiveMinute = typeof usage.cache_creation?.ephemeral_5m_input_tokens === 'number'
|
||||
? count(usage.cache_creation.ephemeral_5m_input_tokens) : Math.max(0, count(usage.cache_creation_input_tokens) - oneHour);
|
||||
const cachedRate = row.cached_input_per_mtok ?? row.input_per_mtok * 0.1;
|
||||
return (count(usage.input_tokens) * row.input_per_mtok
|
||||
+ count(usage.cache_read_input_tokens) * cachedRate
|
||||
+ fiveMinute * row.input_per_mtok * CACHE_WRITE_MULTIPLIER.ephemeral_5m
|
||||
+ oneHour * row.input_per_mtok * CACHE_WRITE_MULTIPLIER.ephemeral_1h
|
||||
+ count(usage.output_tokens) * row.output_per_mtok) / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billed cost of a session's assistant messages, subagent (sidechain) turns
|
||||
* included. A message streams as several events sharing one message id whose
|
||||
* usage grows, so each id counts once at its largest output. Each message is
|
||||
* priced at its own model, else `fallbackModel`. Undefined when no message
|
||||
* carries usage or any message's model has no price row.
|
||||
*/
|
||||
export function streamedUsageCostUsd(
|
||||
events: ReadonlyArray<{ message?: { id?: unknown; model?: unknown; usage?: unknown } } | null | undefined>,
|
||||
fallbackModel?: string,
|
||||
): number | undefined {
|
||||
const byId = new Map<string, { model?: string; usage: AnthropicUsage }>();
|
||||
for (const event of events) {
|
||||
const message = event?.message;
|
||||
const usage = message?.usage as AnthropicUsage | undefined;
|
||||
if (!usage || typeof usage !== 'object' || typeof message!.id !== 'string' || message!.model === '<synthetic>') continue;
|
||||
const seen = byId.get(message!.id);
|
||||
if (!seen || (usage.output_tokens ?? 0) >= (seen.usage.output_tokens ?? 0)) {
|
||||
byId.set(message!.id, { model: typeof message!.model === 'string' ? message!.model : fallbackModel, usage });
|
||||
}
|
||||
}
|
||||
if (byId.size === 0) return undefined;
|
||||
let total = 0;
|
||||
for (const { model, usage } of byId.values()) {
|
||||
const cost = anthropicUsageCostUsd(usage, model);
|
||||
if (cost === undefined) return undefined;
|
||||
total += cost;
|
||||
}
|
||||
return +total.toFixed(6);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* gstack-flaky quarantine (GSTA-158 P2): a registry of known-flaky tests at
|
||||
* `.gstack/quarantine.json`. A quarantined test still runs; it gets a larger
|
||||
* attempt budget (lib/flaky-run.ts, QUARANTINE_ATTEMPTS) under the same full
|
||||
* green verdict, and it is never counted as a pass. This file holds the
|
||||
* registry: schema, validation, add/renew/release, `check` against the base
|
||||
* branch, and `report` over downloaded ledgers.
|
||||
*
|
||||
* Rules (docs/flaky.md § Quarantine):
|
||||
* - Every entry has an owner, a ledger link, a failure class (detector,
|
||||
* harness, timing, model-latency; product defects and infra are refused),
|
||||
* evidence and an expiry: 7 days by default, at most 14 from entry, and one
|
||||
* renewal of at most 7 days with new evidence and the owner named again.
|
||||
* - An expired or invalid entry is not quarantined; nothing needs editing.
|
||||
* - Live entries are capped at max(1, 10% of the suite's tests), and 5 for a
|
||||
* suite under 50 tests.
|
||||
* - `run --base` reads the registry from the base branch so a change cannot
|
||||
* quarantine the test it breaks; `check --base` refuses an entry added in
|
||||
* the change that edits its test's file, an entry that disappears without a
|
||||
* release record, a release record that was removed, and rewritten history.
|
||||
* - gstack's own CASE_QUARANTINE / EVAL_POLICY (paid evals) is a separate
|
||||
* registry; the two share no schema.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { parseTestId, readJunit, type JunitRunner } from './junit';
|
||||
import { safeText } from './flaky-run';
|
||||
|
||||
export const REGISTRY_PATH = '.gstack/quarantine.json';
|
||||
export const FAILURE_CLASSES = ['detector', 'harness', 'timing', 'model-latency'] as const;
|
||||
export type FailureClass = typeof FAILURE_CLASSES[number];
|
||||
export const DEFAULT_DAYS = 7;
|
||||
export const MAX_DAYS = 14;
|
||||
export const RENEW_MAX_DAYS = 7;
|
||||
export const EXIT_RULE_PASSES = 10;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
export interface Renewal { renewedAt: string; previousExpiresAt: string; expiresAt: string; owner: string; evidence: string[] }
|
||||
|
||||
export interface QuarantineEntry {
|
||||
owner: string;
|
||||
/** Link to the flaky ledger line or issue that tracks the fix. */
|
||||
ledger: string;
|
||||
failureClass: FailureClass;
|
||||
reason: string;
|
||||
evidence: string[];
|
||||
enteredAt: string;
|
||||
expiresAt: string;
|
||||
renewals: Renewal[];
|
||||
/** The suite's test count when the entry was added (`add --junit`), the base of the cap. */
|
||||
suiteTests: number;
|
||||
}
|
||||
|
||||
export type ReleasedEntry = Omit<QuarantineEntry, 'reason'> & { id: string; releasedAt: string; reason: string; pr: string };
|
||||
|
||||
export interface Registry { schema: 1; entries: Record<string, QuarantineEntry>; released: ReleasedEntry[] }
|
||||
|
||||
export class QuarantineError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly exitCode: 1 | 2 | 3 = 3) { super(message); }
|
||||
}
|
||||
|
||||
export const emptyRegistry = (): Registry => ({ schema: 1, entries: {}, released: [] });
|
||||
|
||||
/** Live entries allowed for a suite of `tests` tests. */
|
||||
export const suiteCap = (tests: number) => tests < 50 ? 5 : Math.max(1, Math.floor(tests / 10));
|
||||
|
||||
function git(args: string[], cwd: string): { ok: boolean; out: string } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 15_000 });
|
||||
return { ok: r.status === 0, out: r.stdout ?? '' };
|
||||
}
|
||||
|
||||
export function repoRoot(cwd: string): string {
|
||||
const r = git(['rev-parse', '--show-toplevel'], cwd);
|
||||
return r.ok && r.out.trim() ? r.out.trim() : cwd;
|
||||
}
|
||||
|
||||
export function parseRegistry(text: string, source: string): Registry {
|
||||
let data: unknown;
|
||||
try { data = JSON.parse(text); } catch (e) {
|
||||
throw new QuarantineError('FLAKY_QUARANTINE_INVALID', `${source} is not valid JSON (${(e as Error).message})`);
|
||||
}
|
||||
const r = data as Partial<Registry> | null;
|
||||
if (!r || typeof r !== 'object' || r.schema !== 1 || !r.entries || typeof r.entries !== 'object' || Array.isArray(r.entries) || !Array.isArray(r.released)) {
|
||||
throw new QuarantineError('FLAKY_QUARANTINE_INVALID', `${source} is not a schema 1 quarantine registry ({"schema":1,"entries":{},"released":[]})`);
|
||||
}
|
||||
return r as Registry;
|
||||
}
|
||||
|
||||
function resolveCommit(ref: string, cwd: string): string {
|
||||
if (!ref || ref.startsWith('-')) throw new QuarantineError('FLAKY_QUARANTINE_BASE', `--base ${JSON.stringify(ref)} is not a git ref`, 2);
|
||||
const r = git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);
|
||||
if (!r.ok) throw new QuarantineError('FLAKY_QUARANTINE_BASE', `--base ${ref} does not resolve to a commit in ${cwd}`);
|
||||
return r.out.trim();
|
||||
}
|
||||
|
||||
function registryAt(commit: string, root: string, label: string): Registry {
|
||||
if (!git(['cat-file', '-e', `${commit}:${REGISTRY_PATH}`], root).ok) return emptyRegistry();
|
||||
const shown = git(['show', `${commit}:${REGISTRY_PATH}`], root);
|
||||
if (!shown.ok) throw new QuarantineError('FLAKY_QUARANTINE_BASE', `cannot read ${REGISTRY_PATH} at ${label}`);
|
||||
return parseRegistry(shown.out, `${REGISTRY_PATH} at ${label}`);
|
||||
}
|
||||
|
||||
/** The registry in the working tree, or at `base` when given; a missing file is an empty registry. */
|
||||
export function readRegistry(cwd: string, base?: string): Registry {
|
||||
const root = repoRoot(cwd);
|
||||
if (base !== undefined) return registryAt(resolveCommit(base, root), root, base);
|
||||
const file = path.join(root, REGISTRY_PATH);
|
||||
if (!fs.existsSync(file)) return emptyRegistry();
|
||||
return parseRegistry(fs.readFileSync(file, 'utf-8'), REGISTRY_PATH);
|
||||
}
|
||||
|
||||
export function writeRegistry(cwd: string, reg: Registry): string {
|
||||
const file = path.join(repoRoot(cwd), REGISTRY_PATH);
|
||||
const entries = Object.fromEntries(Object.keys(reg.entries).sort().map(id => [id, reg.entries[id]!]));
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify({ schema: 1, entries, released: reg.released }, null, 2) + '\n');
|
||||
return file;
|
||||
}
|
||||
|
||||
const filled = (s: unknown): s is string => typeof s === 'string' && s.trim().length > 0;
|
||||
const filledList = (l: unknown): l is string[] => Array.isArray(l) && l.length > 0 && l.every(filled);
|
||||
|
||||
export function classRefusal(c: unknown): string | undefined {
|
||||
if (FAILURE_CLASSES.includes(c as FailureClass)) return undefined;
|
||||
if (c === 'product') return 'class product is refused: a product defect is a real red; fix it instead of quarantining it';
|
||||
if (c === 'infra') return 'class infra is refused: an infrastructure red is rerun or fixed as infra, never quarantined';
|
||||
return `class ${JSON.stringify(c)} is not one of ${FAILURE_CLASSES.join(', ')}`;
|
||||
}
|
||||
|
||||
/** What makes one entry invalid; an invalid entry is never quarantined and `check` refuses it. */
|
||||
export function entryProblems(id: string, e: QuarantineEntry, now: Date): string[] {
|
||||
if (!e || typeof e !== 'object') return ['not an object'];
|
||||
const p: string[] = [];
|
||||
if (!parseTestId(id)) p.push('not a test id (<file>::<project>::<title>; gstack-flaky ids prints them)');
|
||||
if (!filled(e.owner)) p.push('no owner');
|
||||
if (!filled(e.ledger)) p.push('no ledger link');
|
||||
if (!filled(e.reason)) p.push('no reason');
|
||||
const cls = classRefusal(e.failureClass);
|
||||
if (cls) p.push(cls);
|
||||
if (!filledList(e.evidence)) p.push('no evidence');
|
||||
if (!(Number.isInteger(e.suiteTests) && e.suiteTests > 0)) p.push('no suiteTests (add records the suite size from --junit)');
|
||||
const entered = Date.parse(e.enteredAt);
|
||||
const expires = Date.parse(e.expiresAt);
|
||||
if (Number.isNaN(entered) || Number.isNaN(expires)) return [...p, 'enteredAt or expiresAt is not a date'];
|
||||
if (entered > now.getTime() + DAY) p.push('enteredAt is in the future');
|
||||
if (!Array.isArray(e.renewals)) return [...p, 'renewals is not a list'];
|
||||
if (e.renewals.length > 1) p.push(`renewed ${e.renewals.length} times; one renewal is allowed, then fix the test or release it`);
|
||||
const firstExpiry = e.renewals.length ? Date.parse(e.renewals[0]?.previousExpiresAt) : expires;
|
||||
if (!(firstExpiry > entered)) p.push('expires before it entered');
|
||||
if (firstExpiry - entered > MAX_DAYS * DAY) p.push(`expires more than ${MAX_DAYS} days after it entered`);
|
||||
for (const r of e.renewals) {
|
||||
if (!r || typeof r !== 'object') { p.push('a renewal is not an object'); continue; }
|
||||
const renewed = Date.parse(r.renewedAt);
|
||||
if (!(renewed >= entered && renewed <= now.getTime() + DAY)) p.push('a renewal is dated before the entry or in the future');
|
||||
if (!filled(r?.owner)) p.push('a renewal names no owner');
|
||||
if (!filledList(r?.evidence) || r.evidence.every(x => filledList(e.evidence) && e.evidence.includes(x))) p.push('a renewal carries no new evidence');
|
||||
const from = Math.max(Date.parse(r?.renewedAt), Date.parse(r?.previousExpiresAt));
|
||||
const until = Date.parse(r?.expiresAt);
|
||||
if (Number.isNaN(from) || Number.isNaN(until) || until - from > RENEW_MAX_DAYS * DAY) p.push(`a renewal is not a date range of at most ${RENEW_MAX_DAYS} days`);
|
||||
}
|
||||
const last = e.renewals.at(-1);
|
||||
if (last && last.expiresAt !== e.expiresAt) p.push('expiresAt differs from the last renewal');
|
||||
return p;
|
||||
}
|
||||
|
||||
export const isLive = (e: QuarantineEntry, now: Date) => Date.parse(e.expiresAt) > now.getTime();
|
||||
|
||||
/** Ids `run` gives the quarantine budget, plus one notice per entry it ignores (expired or invalid). */
|
||||
export function liveQuarantine(reg: Registry, now: Date): { ids: Set<string>; notices: string[] } {
|
||||
const ids = new Set<string>();
|
||||
const notices: string[] = [];
|
||||
for (const [id, e] of Object.entries(reg.entries)) {
|
||||
const problems = entryProblems(id, e, now);
|
||||
if (problems.length) notices.push(`FLAKY_QUARANTINE_IGNORED: ${safeText(id)} is not quarantined: ${problems.join('; ')}`);
|
||||
else if (!isLive(e, now)) notices.push(expiredNotice(id, e));
|
||||
else ids.add(id);
|
||||
}
|
||||
return { ids, notices };
|
||||
}
|
||||
|
||||
const shq = (s: string) => "'" + s.split("'").join(`'"'"'`) + "'";
|
||||
export const renewCommand = (id: string) => `gstack-flaky quarantine renew ${shq(safeText(id))} --owner <you> --evidence <new evidence url>`;
|
||||
export const releaseCommand = (id: string) => `gstack-flaky quarantine release ${shq(safeText(id))} --reason <why> --pr <fix pr url>`;
|
||||
|
||||
export function expiredNotice(id: string, e: QuarantineEntry): string {
|
||||
return `FLAKY_QUARANTINE_EXPIRED: ${safeText(id)} expired ${e.expiresAt.slice(0, 10)} and is not quarantined; renew: ${renewCommand(id)} | release: ${releaseCommand(id)}`;
|
||||
}
|
||||
|
||||
export interface AddInput { owner?: string; ledger?: string; failureClass?: string; reason?: string; evidence: string[]; days?: number; suiteIds: string[] }
|
||||
|
||||
const refuse = (msg: string): never => { throw new QuarantineError('FLAKY_QUARANTINE_REFUSED', msg, 1); };
|
||||
|
||||
export function addEntry(reg: Registry, id: string, a: AddInput, now: Date): QuarantineEntry {
|
||||
const days = a.days ?? DEFAULT_DAYS;
|
||||
if (!Number.isInteger(days) || days < 1 || days > MAX_DAYS) refuse(`--days must be a whole number from 1 to ${MAX_DAYS}, got ${a.days}`);
|
||||
const cls = classRefusal(a.failureClass);
|
||||
if (cls) refuse(cls);
|
||||
if (!a.evidence.length) refuse('no evidence: pass --evidence <url> (a failing run, a ledger line) at least once');
|
||||
if (!a.suiteIds.includes(id)) refuse(`${safeText(id)} is not a test in the --junit report (gstack-flaky ids --junit <file> prints the ids)`);
|
||||
if (reg.entries[id]) refuse(`${safeText(id)} already has an entry; renew or release it`);
|
||||
const live = Object.values(reg.entries).filter(e => isLive(e, now)).length;
|
||||
const cap = suiteCap(a.suiteIds.length);
|
||||
if (live + 1 > cap) refuse(`the registry holds ${live} live entries and the cap for a ${a.suiteIds.length}-test suite is ${cap}; fix or release one first`);
|
||||
const entry: QuarantineEntry = {
|
||||
owner: a.owner ?? '', ledger: a.ledger ?? '', failureClass: a.failureClass as FailureClass, reason: a.reason ?? '', evidence: a.evidence,
|
||||
enteredAt: now.toISOString(), expiresAt: new Date(now.getTime() + days * DAY).toISOString(), renewals: [], suiteTests: a.suiteIds.length,
|
||||
};
|
||||
const problems = entryProblems(id, entry, now);
|
||||
if (problems.length) refuse(problems.join('; '));
|
||||
reg.entries[id] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function renewEntry(reg: Registry, id: string, r: { owner?: string; evidence: string[]; days?: number }, now: Date): QuarantineEntry {
|
||||
const e = reg.entries[id] ?? refuse(`${safeText(id)} has no entry to renew`);
|
||||
if (e.renewals.length) refuse(`${safeText(id)} was renewed once already; a second renewal is refused: fix the test or release it`);
|
||||
if (!filled(r.owner)) refuse('renew names the owner again: pass --owner');
|
||||
if (!r.evidence.some(x => filled(x) && !e.evidence.includes(x))) refuse('renew needs new evidence: pass --evidence <url> not already on the entry');
|
||||
const days = r.days ?? RENEW_MAX_DAYS;
|
||||
if (!Number.isInteger(days) || days < 1 || days > RENEW_MAX_DAYS) refuse(`a renewal is 1 to ${RENEW_MAX_DAYS} days, got ${r.days}`);
|
||||
const from = Math.max(now.getTime(), Date.parse(e.expiresAt));
|
||||
const expiresAt = new Date(from + days * DAY).toISOString();
|
||||
e.renewals.push({ renewedAt: now.toISOString(), previousExpiresAt: e.expiresAt, expiresAt, owner: r.owner!, evidence: r.evidence });
|
||||
e.expiresAt = expiresAt;
|
||||
return e;
|
||||
}
|
||||
|
||||
export function releaseEntry(reg: Registry, id: string, r: { reason?: string; pr?: string }, now: Date): ReleasedEntry {
|
||||
const e = reg.entries[id] ?? refuse(`${safeText(id)} has no entry to release`);
|
||||
if (!filled(r.reason) || !filled(r.pr)) refuse('release needs --reason and --pr (the fix, or the decision to stop quarantining)');
|
||||
const record: ReleasedEntry = { id, ...e, releasedAt: now.toISOString(), reason: r.reason!, pr: r.pr! };
|
||||
reg.released.push(record);
|
||||
delete reg.entries[id];
|
||||
return record;
|
||||
}
|
||||
|
||||
const liveEntries = (reg: Registry, now: Date) => Object.values(reg.entries).filter(e => e && typeof e === 'object' && isLive(e, now));
|
||||
const smallestSuite = (live: QuarantineEntry[]) => Math.min(...live.map(e => e.suiteTests).filter(n => Number.isInteger(n) && n > 0));
|
||||
|
||||
/** Structural problems of a whole registry plus the cap, for `check`. */
|
||||
export function registryProblems(reg: Registry, now: Date, suiteTests?: number): string[] {
|
||||
const p: string[] = [];
|
||||
for (const [id, e] of Object.entries(reg.entries)) for (const x of entryProblems(id, e, now)) p.push(`${safeText(id)}: ${x}`);
|
||||
for (const r of reg.released) {
|
||||
if (!r || !filled(r.id) || Number.isNaN(Date.parse(r.releasedAt)) || !filled(r.reason) || !filled(r.pr)) p.push(`released record ${JSON.stringify(r?.id)}: needs id, releasedAt, reason and pr`);
|
||||
}
|
||||
const live = liveEntries(reg, now);
|
||||
const tests = suiteTests ?? smallestSuite(live);
|
||||
if (live.length && Number.isFinite(tests) && live.length > suiteCap(tests)) p.push(`${live.length} live entries, over the cap of ${suiteCap(tests)} for a ${tests}-test suite`);
|
||||
return p;
|
||||
}
|
||||
|
||||
const editsFile = (changed: string[], file: string) => changed.some(f => f === file || f.endsWith(`/${file}`));
|
||||
|
||||
/** What a change did to the registry that `check --base` refuses. */
|
||||
export function baseProblems(base: Registry, head: Registry, changed: string[]): string[] {
|
||||
const p: string[] = [];
|
||||
for (const [id, e] of Object.entries(head.entries)) {
|
||||
const was = base.entries[id];
|
||||
const t = parseTestId(id);
|
||||
if (!was) {
|
||||
if (t && editsFile(changed, t.file)) p.push(`${safeText(id)}: added in the same change that edits ${t.file}; a change cannot quarantine the test it touches (land the entry on its own, or fix the test)`);
|
||||
continue;
|
||||
}
|
||||
if (was.enteredAt !== e?.enteredAt) p.push(`${safeText(id)}: enteredAt changed from ${was.enteredAt}; release the entry instead of restarting it`);
|
||||
if (JSON.stringify((e?.renewals ?? []).slice(0, was.renewals.length)) !== JSON.stringify(was.renewals)) p.push(`${safeText(id)}: its renewal history was rewritten`);
|
||||
}
|
||||
for (const [id, was] of Object.entries(base.entries)) {
|
||||
if (!(id in head.entries) && !head.released.some(r => r?.id === id && r.enteredAt === was.enteredAt)) p.push(`${safeText(id)}: removed without a release record (use: ${releaseCommand(id)})`);
|
||||
}
|
||||
for (const r of base.released) {
|
||||
if (!head.released.some(h => h?.id === r.id && h.releasedAt === r.releasedAt)) p.push(`${safeText(r.id)}: its release record of ${r.releasedAt} was removed`);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** `check`: the working-tree registry alone, and with `base` against what this change did since the merge base. */
|
||||
export function checkRegistry(cwd: string, o: { base?: string; suiteTests?: number }, now: Date): { problems: string[]; live: number; cap?: number } {
|
||||
const head = readRegistry(cwd);
|
||||
const problems = registryProblems(head, now, o.suiteTests);
|
||||
if (o.base !== undefined) {
|
||||
const root = repoRoot(cwd);
|
||||
const commit = resolveCommit(o.base, root);
|
||||
const mb = git(['merge-base', commit, 'HEAD'], root);
|
||||
if (!mb.ok) throw new QuarantineError('FLAKY_QUARANTINE_BASE', `no merge base between ${o.base} and HEAD (a shallow clone?)`);
|
||||
const changed = git(['-c', 'core.quotePath=false', 'diff', '--name-only', '-z', mb.out.trim()], root);
|
||||
if (!changed.ok) throw new QuarantineError('FLAKY_QUARANTINE_BASE', `cannot diff against the merge base of ${o.base}`);
|
||||
problems.push(...baseProblems(registryAt(mb.out.trim(), root, `the merge base of ${o.base}`), head, changed.out.split('\0').filter(Boolean)));
|
||||
}
|
||||
const live = liveEntries(head, now);
|
||||
const tests = o.suiteTests ?? smallestSuite(live);
|
||||
return { problems, live: live.length, ...(Number.isFinite(tests) ? { cap: suiteCap(tests) } : {}) };
|
||||
}
|
||||
|
||||
export interface LedgerRecord { ts: string; kind: string; id: string; attempts?: string[] }
|
||||
|
||||
/** Read every JSON line of the given ledger files, and of every .jsonl file under the given directories. */
|
||||
export function readLedgers(paths: string[]): { records: LedgerRecord[]; skipped: number } {
|
||||
const files: string[] = [];
|
||||
const walk = (p: string) => {
|
||||
const st = fs.statSync(p, { throwIfNoEntry: false });
|
||||
if (!st) throw new QuarantineError('FLAKY_QUARANTINE_USAGE', `--ledger ${p} does not exist`, 2);
|
||||
if (!st.isDirectory()) { files.push(p); return; }
|
||||
for (const n of fs.readdirSync(p).sort()) {
|
||||
const c = path.join(p, n);
|
||||
if (fs.statSync(c).isDirectory() || n.endsWith('.jsonl')) walk(c);
|
||||
}
|
||||
};
|
||||
paths.forEach(walk);
|
||||
const records: LedgerRecord[] = [];
|
||||
let skipped = 0;
|
||||
for (const f of files) {
|
||||
for (const l of fs.readFileSync(f, 'utf-8').split('\n')) {
|
||||
if (!l.trim()) continue;
|
||||
try {
|
||||
const r = JSON.parse(l) as LedgerRecord;
|
||||
if (filled(r?.id) && filled(r.kind) && filled(r.ts)) records.push(r); else skipped++;
|
||||
} catch { skipped++; }
|
||||
}
|
||||
}
|
||||
return { records, skipped };
|
||||
}
|
||||
|
||||
const cleanPass = (r: LedgerRecord) => r.kind === 'quarantined-pass' && !(r.attempts ?? []).includes('fail');
|
||||
const shortKind = (r: LedgerRecord) => cleanPass(r) ? 'pass' : r.kind === 'quarantined-pass' ? 'flaky' : r.kind === 'quarantined-fail' ? 'fail' : r.kind;
|
||||
|
||||
/** `report`: expired entries first, then live entries soonest-expiring first, with ledger history when given. */
|
||||
export function reportLines(reg: Registry, now: Date, ledgers?: LedgerRecord[]): string[] {
|
||||
const out: string[] = [];
|
||||
const all = Object.entries(reg.entries).sort((a, b) => Date.parse(a[1].expiresAt) - Date.parse(b[1].expiresAt));
|
||||
const expired = all.filter(([, e]) => !isLive(e, now));
|
||||
const live = all.filter(([, e]) => isLive(e, now));
|
||||
for (const [id, e] of expired) out.push(expiredNotice(id, e));
|
||||
let expiring = 0, ready = 0;
|
||||
for (const [id, e] of live) {
|
||||
const daysLeft = Math.ceil((Date.parse(e.expiresAt) - now.getTime()) / DAY);
|
||||
if (daysLeft <= 2) expiring++;
|
||||
let history = '';
|
||||
if (ledgers) {
|
||||
const sid = safeText(id);
|
||||
const mine = ledgers.filter(r => r.id === sid && Date.parse(r.ts) >= Date.parse(e.enteredAt)).sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
|
||||
let streak = 0;
|
||||
for (let i = mine.length - 1; i >= 0 && cleanPass(mine[i]!); i--) streak++;
|
||||
const isReady = streak >= EXIT_RULE_PASSES;
|
||||
if (isReady) ready++;
|
||||
history = ` recent=${mine.slice(-5).map(shortKind).join(',') || 'none'} clean_streak=${streak}${isReady ? ` READY_TO_RELEASE: ${releaseCommand(id)}` : ''}`;
|
||||
}
|
||||
const renewal = e.renewals.length ? 'renewed once; fix or release' : `renew: ${renewCommand(id)}`;
|
||||
out.push(`QUARANTINE: ${safeText(id)} owner=${safeText(e.owner)} class=${e.failureClass} days_left=${daysLeft} expires=${e.expiresAt.slice(0, 10)} ledger=${safeText(e.ledger)}${history}${daysLeft <= 2 ? ` EXPIRING (${renewal})` : ''}`);
|
||||
}
|
||||
out.push(`QUARANTINE_REPORT: live=${live.length} expired=${expired.length} expiring=${expiring} ready=${ledgers ? ready : 'unchecked'} exit_rule=${ledgers ? `checked (${EXIT_RULE_PASSES} consecutive clean passes)` : 'not checked (no --ledger given)'}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const QUARANTINE_USAGE = `Usage:
|
||||
gstack-flaky quarantine add <id> --owner <who> --ledger <url> --class detector|harness|timing|model-latency
|
||||
--reason <text> --evidence <url> [--evidence <url>...] --junit <file> [--runner <r>] [--days <1-${MAX_DAYS}>]
|
||||
gstack-flaky quarantine renew <id> --owner <who> --evidence <new url> [--days <1-${RENEW_MAX_DAYS}>]
|
||||
gstack-flaky quarantine release <id> --reason <text> --pr <url>
|
||||
gstack-flaky quarantine check [--base <ref>] [--junit <file>]
|
||||
gstack-flaky quarantine report [--ledger <file|dir>...] [--base <ref>]
|
||||
|
||||
add/renew/release edit ${REGISTRY_PATH} at the repository root. check validates it
|
||||
(owner, ledger, class, evidence, expiry, one renewal, the cap) and, with --base,
|
||||
refuses an entry added in the change that edits its test's file, an entry removed
|
||||
without a release record, and a removed release record. report lists expired and
|
||||
live entries, soonest-expiring first; with --ledger (files or directories of
|
||||
downloaded ledgers) it shows recent outcomes and marks entries with ${EXIT_RULE_PASSES}
|
||||
consecutive clean passes READY_TO_RELEASE.
|
||||
Exit: 0 ok, 1 refused, 2 usage, 3 unreadable registry or base.
|
||||
`;
|
||||
|
||||
interface CliIO { cwd: string; out: (s: string) => void; now: Date }
|
||||
|
||||
const usage = (msg: string): never => { throw new QuarantineError('FLAKY_QUARANTINE_USAGE', msg, 2); };
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const flags: Record<string, string[]> = {};
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (!a.startsWith('--')) { positional.push(a); continue; }
|
||||
const v = argv[++i];
|
||||
if (v === undefined) usage(`${a} needs a value`);
|
||||
(flags[a.slice(2)] ??= []).push(v!);
|
||||
}
|
||||
return { flags, positional };
|
||||
}
|
||||
|
||||
function intFlag(v: string | undefined, name: string): number | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n)) usage(`--${name} must be a whole number`);
|
||||
return n;
|
||||
}
|
||||
|
||||
const ALLOWED: Record<string, string[]> = {
|
||||
add: ['owner', 'ledger', 'class', 'reason', 'evidence', 'junit', 'runner', 'days'], renew: ['owner', 'evidence', 'days'],
|
||||
release: ['reason', 'pr'], check: ['base', 'junit', 'runner'], report: ['ledger', 'base'],
|
||||
};
|
||||
|
||||
/** The `gstack-flaky quarantine` subcommands; returns the exit code. A JunitError from --junit propagates to the bin. */
|
||||
export function quarantineMain(argv: string[], io: CliIO): number {
|
||||
const [sub, ...rest] = argv;
|
||||
if (!sub || sub === '--help' || sub === '-h') { io.out(QUARANTINE_USAGE); return sub ? 0 : 2; }
|
||||
if (!ALLOWED[sub]) usage(`unknown quarantine command ${sub}\n${QUARANTINE_USAGE}`);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
const unknown = Object.keys(flags).find(f => !ALLOWED[sub]!.includes(f));
|
||||
if (unknown) usage(`${sub} does not take --${unknown}`);
|
||||
const one = (f: string) => flags[f]?.at(-1);
|
||||
const runner = one('runner') as JunitRunner | undefined;
|
||||
if (runner !== undefined && !['playwright', 'bun', 'generic'].includes(runner)) usage('--runner must be playwright, bun or generic');
|
||||
const needsId = sub === 'add' || sub === 'renew' || sub === 'release';
|
||||
if (needsId !== (positional.length === 1) || positional.length > 1) usage(needsId ? `${sub} takes one test id` : `${sub} takes no test id`);
|
||||
if (sub === 'check') {
|
||||
const junit = one('junit');
|
||||
const r = checkRegistry(io.cwd, { base: one('base'), suiteTests: junit ? readJunit(junit, runner).tests.length : undefined }, io.now);
|
||||
for (const p of r.problems) io.out(`QUARANTINE_REFUSED: ${p}\n`);
|
||||
io.out(`QUARANTINE_CHECK: live=${r.live} cap=${r.cap ?? 'none'} base=${one('base') ?? 'none'} verdict=${r.problems.length ? 'fail' : 'pass'}\n`);
|
||||
return r.problems.length ? 1 : 0;
|
||||
}
|
||||
if (sub === 'report') {
|
||||
const ledgers = flags.ledger ? readLedgers(flags.ledger) : undefined;
|
||||
if (ledgers?.skipped) io.out(`QUARANTINE_LEDGER: skipped ${ledgers.skipped} unreadable line(s)\n`);
|
||||
for (const l of reportLines(readRegistry(io.cwd, one('base')), io.now, ledgers?.records)) io.out(`${l}\n`);
|
||||
return 0;
|
||||
}
|
||||
const id = positional[0]!;
|
||||
const reg = readRegistry(io.cwd);
|
||||
let line: string;
|
||||
if (sub === 'add') {
|
||||
const junit = one('junit') ?? refuse('add needs --junit <file> to count the suite and confirm the id exists');
|
||||
const suiteIds = readJunit(junit, runner).tests.map(t => t.id);
|
||||
const e = addEntry(reg, id, { owner: one('owner'), ledger: one('ledger'), failureClass: one('class'), reason: one('reason'), evidence: flags.evidence ?? [], days: intFlag(one('days'), 'days'), suiteIds }, io.now);
|
||||
line = `QUARANTINE_ADDED: ${safeText(id)} owner=${safeText(e.owner)} class=${e.failureClass} expires=${e.expiresAt}`;
|
||||
} else if (sub === 'renew') {
|
||||
const e = renewEntry(reg, id, { owner: one('owner'), evidence: flags.evidence ?? [], days: intFlag(one('days'), 'days') }, io.now);
|
||||
line = `QUARANTINE_RENEWED: ${safeText(id)} owner=${safeText(e.renewals[0]!.owner)} expires=${e.expiresAt} (no further renewal)`;
|
||||
} else {
|
||||
const r = releaseEntry(reg, id, { reason: one('reason'), pr: one('pr') }, io.now);
|
||||
line = `QUARANTINE_RELEASED: ${safeText(id)} pr=${safeText(r.pr)}`;
|
||||
}
|
||||
writeRegistry(io.cwd, reg);
|
||||
io.out(`${line} registry=${REGISTRY_PATH}\n`);
|
||||
return 0;
|
||||
}
|
||||
+23
-3
@@ -20,7 +20,7 @@ export type PathClass = 'docs' | 'tests' | 'code';
|
||||
export type Depth = 'light' | 'standard' | 'full';
|
||||
export type Effort = 'light' | 'medium' | 'full' | 'host';
|
||||
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted';
|
||||
export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop';
|
||||
export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'done' | 'stop';
|
||||
|
||||
export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const;
|
||||
export type Specialist = (typeof SPECIALISTS)[number];
|
||||
@@ -345,6 +345,12 @@ export interface History {
|
||||
/** Runtime lines of the fix range (RANGE_BASE..HEAD) as classifyDiff counts them, 0 when it touches none; undefined or null when the range was not read. */
|
||||
rangeRuntimeLines?: number | null;
|
||||
pass1Depth?: Depth;
|
||||
/** The diff is gate class (planReview sets it): Step 11's voices run before any stop. */
|
||||
gate?: boolean;
|
||||
/** Step 11's native adversarial pass already ran (planReview sets it from PlanInput.adversarialRan). */
|
||||
adversarialRan?: boolean;
|
||||
/** The approval id of the owner's recorded grant of one scoped fix-check after round 2 (review_loop_exception). */
|
||||
granted?: string;
|
||||
}
|
||||
export interface NextStep { step: Step; reason: string }
|
||||
|
||||
@@ -356,8 +362,19 @@ export interface NextStep { step: Step; reason: string }
|
||||
* changes no runtime lines; two rounds is the cap (R51: a scoped pass or a fix-check is
|
||||
* one): after round 2, below-floor fixes finish with notes, and a blocker open or fixed
|
||||
* there, or a gate-class edit there, stops for the owner, whose grant may add one scoped fix-check.
|
||||
* The cap limits fix rounds, not Step 11's voices: on gate class any stop waits for them (GST-967),
|
||||
* and the owner's recorded grant (`granted`) turns the stop after a round-2 full pass with a fixed finding into its
|
||||
* one fix-check; a fix-check never earns another, so the grant cannot repeat.
|
||||
*/
|
||||
export function nextStep(h: History, depth?: Depth): NextStep {
|
||||
const n = loopStep(h, depth);
|
||||
if (n.step !== 'stop') return n;
|
||||
if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's voices (adversarial, outside, targeted) run before the owner's exit, then ask again with --adversarial-ran (${n.reason})` };
|
||||
if (h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` };
|
||||
return n;
|
||||
}
|
||||
|
||||
function loopStep(h: History, depth?: Depth): NextStep {
|
||||
if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' };
|
||||
const fixed = !!(h.fixedBlocking || h.fixedBelow);
|
||||
if (h.last === 'fix-check') return h.blockingOpen || h.fixedBlocking || h.touchedGate ? { step: 'stop', reason: `the fix-check ${h.touchedGate && !h.blockingOpen && !h.fixedBlocking ? 'edited a gate-class path' : 'found a blocking finding'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` } : { step: 'done', reason: fixed ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' };
|
||||
@@ -489,7 +506,7 @@ export function planReview(i: PlanInput): ReviewPlan {
|
||||
const c = classifyDiff(i);
|
||||
const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor);
|
||||
const { class: cls, runtimeLines } = depth;
|
||||
const next = nextStep(i.history, depth.depth);
|
||||
const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan }, depth.depth);
|
||||
const h = i.history;
|
||||
const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
|
||||
const stepDepth: Depth = raise ? 'standard' : depth.depth;
|
||||
@@ -551,8 +568,11 @@ export function planReview(i: PlanInput): ReviewPlan {
|
||||
} else if (next.step === 'fix-check') {
|
||||
add('fix-check', 'fix-check', 'full');
|
||||
if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
|
||||
} else if (next.step === 'voices') {
|
||||
add('adversarial-native', 'adversarial', 'full');
|
||||
add('outside-voice', 'outside-voice', 'host');
|
||||
}
|
||||
if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check'].includes(next.step)) {
|
||||
if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check', 'voices'].includes(next.step)) {
|
||||
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
|
||||
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
|
||||
}
|
||||
|
||||
+38
-22
@@ -27,7 +27,7 @@ import { versionAtRef } from './restamp';
|
||||
import { globToRegExp, type ShipPolicy } from './ship-policy';
|
||||
import { ciAtHead, type QueueContext } from './ship-queue';
|
||||
import { git, sha256, type QueueFailure } from './ship-queue-gate';
|
||||
import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { fetchBase, mergeCommit, mergeStep, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { prInfo, type PrInfo } from './ship-queue-train-github';
|
||||
import { listWaves, loadWave, nowIso, persist, queueRoot, rebuildFromNotes, redactCause, wavePath, type ReadyReceipt, type WaveRecord } from './ship-queue-train-record';
|
||||
|
||||
@@ -67,7 +67,8 @@ export function areaKey(file: string, isSkillDir: (dir: string) => boolean): str
|
||||
return parts.length > 1 ? top : 'root';
|
||||
}
|
||||
|
||||
export interface PlanPr { pr: string; head: string; files: string[]; lines: number }
|
||||
/** renamed maps a path the PR renamed to its new path; files lists both. */
|
||||
export interface PlanPr { pr: string; head: string; files: string[]; lines: number; renamed?: Record<string, string> }
|
||||
export interface WaveGroup { area: string; prs: PlanPr[]; integrator: string }
|
||||
|
||||
/** The most common area across files, ties broken by name; 'misc' with no files. */
|
||||
@@ -91,11 +92,13 @@ export const isHubFile = (file: string) => file.startsWith('docs/') || /\.(md|js
|
||||
* Group PRs into waves: PRs whose changes to a common code file conflict always travel together
|
||||
* (one integrator resolves the conflict once); hub files never link. conflicts(a, b) names the
|
||||
* files a merge of the two heads cannot resolve, or null when unknown, which counts every shared
|
||||
* file as conflicting. Each cluster takes its largest PR's area, clusters of one area are packed
|
||||
* in PR order into waves of at most max, and a wave below min rides solo. The integrator is the
|
||||
* PR with the most changed lines.
|
||||
* file as conflicting; a conflict at the path either PR renamed the shared file to counts as one
|
||||
* on that file. Each cluster takes its largest PR's area and is packed whole, first fit in PR
|
||||
* order, into the waves of its area of at most max; a cluster larger than max is oversize (never
|
||||
* split, so its conflicts still meet one integrator), and a wave below min rides solo. The
|
||||
* integrator is the PR with the most changed lines.
|
||||
*/
|
||||
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[] } {
|
||||
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[]; oversize: Array<{ area: string; prs: PlanPr[] }> } {
|
||||
const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr));
|
||||
const root = new Map(sorted.map(p => [p.pr, p.pr]));
|
||||
const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); };
|
||||
@@ -106,7 +109,8 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W
|
||||
const key = `${a.pr} ${b.pr}`;
|
||||
if (!clash.has(key)) clash.set(key, conflicts(a, b));
|
||||
const hit = clash.get(key);
|
||||
if ((!hit || hit.includes(f)) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr));
|
||||
const at = [f, a.renamed?.[f], b.renamed?.[f]];
|
||||
if ((!hit || at.some(p => p && hit.includes(p))) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr));
|
||||
}));
|
||||
const clusters = new Map<string, PlanPr[]>();
|
||||
for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]);
|
||||
@@ -117,17 +121,20 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W
|
||||
}
|
||||
const groups: WaveGroup[] = [];
|
||||
const solo: string[] = [];
|
||||
const oversize: Array<{ area: string; prs: PlanPr[] }> = [];
|
||||
for (const [area, list] of [...byArea].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
let cur: PlanPr[] = [];
|
||||
const flush = () => {
|
||||
if (cur.length >= min) groups.push({ area, prs: cur, integrator: [...cur].sort(largestFirst)[0]!.pr });
|
||||
else solo.push(...cur.map(p => p.pr));
|
||||
cur = [];
|
||||
};
|
||||
for (const c of list) for (const p of c) { if (cur.length >= max) flush(); cur.push(p); }
|
||||
flush();
|
||||
const waves: PlanPr[][] = [];
|
||||
for (const c of list) {
|
||||
if (c.length > max) { oversize.push({ area, prs: c }); continue; }
|
||||
const fit = waves.find(w => w.length + c.length <= max);
|
||||
if (fit) fit.push(...c); else waves.push([...c]);
|
||||
}
|
||||
for (const w of waves) {
|
||||
if (w.length >= min) groups.push({ area, prs: w, integrator: [...w].sort(largestFirst)[0]!.pr });
|
||||
else solo.push(...w.map(p => p.pr));
|
||||
}
|
||||
}
|
||||
return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)) };
|
||||
return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)), oversize };
|
||||
}
|
||||
|
||||
export interface WaveChain { integrator: string[]; merges: string[]; stamps: string[] }
|
||||
@@ -225,10 +232,14 @@ function readyReceipts(env: TrainEnv): ReadyReceipt[] {
|
||||
return [...newest.values()];
|
||||
}
|
||||
|
||||
function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null {
|
||||
function changed(env: TrainEnv, head: string): { files: string[]; lines: number; renamed: Record<string, string> } | null {
|
||||
const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]);
|
||||
const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', '--no-renames', fork.out, head]) : fork;
|
||||
if (!stat.ok) return null;
|
||||
const moves = fork.ok ? git(env.ctx.repoRoot, ['diff', '--name-status', '-z', '-M', '--diff-filter=R', fork.out, head]) : fork;
|
||||
if (!stat.ok || !moves.ok) return null;
|
||||
const renamed: Record<string, string> = {};
|
||||
const fields = moves.out.split('\0');
|
||||
for (let i = 0; i + 2 < fields.length; i += 3) if (!isReleasePath(fields[i + 1]!, env.policy.policy)) renamed[fields[i + 1]!] = fields[i + 2]!;
|
||||
let lines = 0;
|
||||
const files: string[] = [];
|
||||
for (const row of stat.out.split('\n').filter(Boolean)) {
|
||||
@@ -237,7 +248,7 @@ function changed(env: TrainEnv, head: string): { files: string[]; lines: number
|
||||
files.push(file);
|
||||
lines += (Number(add) || 0) + (Number(del) || 0);
|
||||
}
|
||||
return { files, lines };
|
||||
return { files, lines, renamed };
|
||||
}
|
||||
|
||||
const areaOf = (env: TrainEnv) => (f: string) => areaKey(f, dir => fs.existsSync(path.join(env.ctx.repoRoot, dir, 'SKILL.md.tmpl')));
|
||||
@@ -269,12 +280,15 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m
|
||||
if (ci.line.startsWith('unknown')) { unreadable.push(`#${r.pr} (CI ${ci.line})`); continue; }
|
||||
if (!ci.green) { lines.push(`NOT_GREEN: #${r.pr} (${ci.line}; left out until CI is green at the READY head)`); continue; }
|
||||
if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; }
|
||||
const onBase = mergeStep(env.ctx.repoRoot, `${env.remote}/${env.ctx.base}`, r.head, env.policy.policy);
|
||||
if ('conflicts' in onBase && onBase.conflicts.some(f => f.startsWith('('))) { unreadable.push(`#${r.pr} (merge onto ${env.ctx.base} unreadable: ${onBase.conflicts.join(', ')})`); continue; }
|
||||
if ('conflicts' in onBase) { lines.push(`NOT_MERGEABLE: #${r.pr} (conflicts with ${env.remote}/${env.ctx.base} at ${short(r.head)} in ${onBase.conflicts.join(', ')}; left out until the head merges cleanly onto ${env.ctx.base})`); continue; }
|
||||
const c = changed(env, r.head);
|
||||
if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; }
|
||||
live.push({ pr: r.pr, head: r.head, ...c });
|
||||
}
|
||||
if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none');
|
||||
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there); wave trigger ${o.trigger}`);
|
||||
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there, merging cleanly onto ${env.ctx.base}); wave trigger ${o.trigger}`);
|
||||
if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs <pins>`, 'none');
|
||||
const plan = groupWaves(live, areaOf(env), o.min, o.max, (a, b) => {
|
||||
const m = git(env.ctx.repoRoot, ['merge-tree', '--write-tree', '--name-only', '--no-messages', a.head, b.head]);
|
||||
@@ -285,6 +299,7 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m
|
||||
lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`);
|
||||
lines.push(` build: ${buildCmd(g.area, g.prs)}`);
|
||||
}
|
||||
for (const c of plan.oversize) lines.push(`WAVE_OVERSIZE: area=${c.area} prs=${c.prs.map(p => '#' + p.pr).join(',')} (${c.prs.length} PRs whose changes conflict, over the wave maximum ${o.max}; kept whole with no build command: land part of it as single slots, then run wave plan again)`);
|
||||
if (plan.solo.length) lines.push(`SOLO: ${plan.solo.map(p => '#' + p).join(',')} (no area group of ${o.min}+; they ride as single slots)`);
|
||||
const first = plan.groups[0];
|
||||
return outOk(lines, first ? `#${first.integrator}'s owner integrates: ${buildCmd(first.area, first.prs)}` : `${Q} train build --prs <pins>`, 'none');
|
||||
@@ -577,8 +592,9 @@ export function waveSettle(ctx: QueueContext, o: { wave: string; dryRun: boolean
|
||||
export const WAVE_HELP = `gstack-ship-queue wave: wave PRs for merge trains (docs/ship-policy.md#wave-prs)
|
||||
|
||||
wave plan [--trigger <n>] [--min <n>] [--max <n>]
|
||||
read-only: live READY PRs; over the trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and
|
||||
WAVE_GROUP lines (area, PRs, integrator) with each group's build command
|
||||
read-only: live READY PRs (NOT_GREEN and NOT_MERGEABLE heads left out); over the
|
||||
trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and WAVE_GROUP lines (area, PRs, integrator)
|
||||
with each group's build command; WAVE_OVERSIZE for linked PRs over --max (no command)
|
||||
wave build --prs <n>@<sha>,... [--area <name>] [--min <n>] [--max <n>] [--dry-run]
|
||||
the integrator branch wave/<nonce>: main plus each pin merged unchanged (train merge
|
||||
rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice
|
||||
|
||||
+2
-2
@@ -226,8 +226,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run);
|
||||
const atHead = versionAtRef(ctx.repoRoot, head);
|
||||
const baseSha = git(ctx.repoRoot, ['rev-parse', `origin/${ctx.base}`]).out;
|
||||
const atBase = versionAtRef(ctx.repoRoot, baseSha);
|
||||
const version = 'version' in atHead && 'version' in atBase && atHead.version !== atBase.version ? atHead.version : 'unstamped';
|
||||
const atFork = versionAtRef(ctx.repoRoot, git(ctx.repoRoot, ['merge-base', baseSha, head]).out || baseSha);
|
||||
const version = 'version' in atHead && 'version' in atFork && atHead.version !== atFork.version ? atHead.version : 'unstamped';
|
||||
const ci = ciAtHead(ctx, head);
|
||||
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
|
||||
let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
|
||||
import { RECEIPT_FENCE } from './ship-receipt';
|
||||
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
|
||||
|
||||
export interface ProseClaim { field: string; prose: number; receipt: number }
|
||||
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
|
||||
@@ -65,9 +66,24 @@ function withoutApprovalsBlocks(body: string): string {
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The body without the `Review-loop exception: …` line the receipt's own exception requires (GST-977): write and the
|
||||
* receipt check want it word for word, and it quotes the owner's reason (`outside 1 and 2 skip detector`), not this
|
||||
* PR's counters. Only a line whose trimmed text is that rendered line, the test write applies, is left out (a fenced
|
||||
* copy too, as write accepts one); the same words inside a longer line stay prose. A marker line takes its place so
|
||||
* the text on either side never joins into a claim.
|
||||
*/
|
||||
function withoutExceptionLine(body: string, exception: unknown): string {
|
||||
if (exception === undefined) return body;
|
||||
const parsed = parseReviewLoopException(exception);
|
||||
if (!parsed.ok) return body;
|
||||
const line = reviewLoopExceptionLine(parsed.exception);
|
||||
return body.split(/(?<=\n)/).map(l => (l.trim() === line ? '(review-loop exception line)\n' : l)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
|
||||
* without its receipt block and its rendered Approvals block), where `<name>` is a counter's leaf key with `_`
|
||||
* without its receipt block, its rendered Approvals block and its review-loop exception line), where `<name>` is a counter's leaf key with `_`
|
||||
* matched as `_`, space or `-` and not part of a longer word (`3 pass-through`
|
||||
* is not a `pass` claim), compared with the receipt. A leaf key shared by
|
||||
* several counters (`pass` under every gate lane, `passed` under gate and ci)
|
||||
@@ -77,7 +93,7 @@ function withoutApprovalsBlocks(body: string): string {
|
||||
* `<n> <name>` claim about the next key (#3513).
|
||||
*/
|
||||
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
|
||||
const prose = withoutApprovalsBlocks(body.replace(BLOCK_RE, ''));
|
||||
const prose = withoutExceptionLine(withoutApprovalsBlocks(body.replace(BLOCK_RE, '')), receipt.review_loop_exception);
|
||||
const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
|
||||
for (const [field, value] of receiptCounters(receipt)) {
|
||||
const leaf = field.split('.').at(-1)!;
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"preamble:plan-mode-info",
|
||||
"preamble:ask-user-format",
|
||||
"preamble:completion-status",
|
||||
"preamble:evidence",
|
||||
"section:autoplan/unattended",
|
||||
"section:ship/review-army",
|
||||
"section:ship/adversarial"
|
||||
@@ -58,7 +59,6 @@
|
||||
"preamble:writing-style",
|
||||
"preamble:completeness",
|
||||
"preamble:confusion",
|
||||
"preamble:evidence",
|
||||
"preamble:context-health",
|
||||
"preamble:question-tuning",
|
||||
"preamble:search-before-building",
|
||||
@@ -66,7 +66,7 @@
|
||||
"preamble:brain-health",
|
||||
"preamble:context-recovery"
|
||||
],
|
||||
"note": "Opus 5.5 only (GSTA-161). Drops eleven coaching preamble pieces and keeps upgrade-check and brain-sync. Evidence: the GSTA-161 office-hours preamble run (handoffs/GSTA-161/preamble-measurement.md) and the three-arm land-and-deploy confirmation cited in docs/loadouts.md."
|
||||
"note": "Opus 5.5 only (GSTA-161). Drops ten coaching preamble pieces and keeps upgrade-check, brain-sync and the pinned Claims Need Evidence rule (GST-944). Evidence: the GSTA-161 office-hours preamble run (handoffs/GSTA-161/preamble-measurement.md) and the three-arm land-and-deploy confirmation cited in docs/loadouts.md."
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.7",
|
||||
"version": "1.93.17",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface GenerationOptions {
|
||||
catalogMode?: 'trim' | 'full';
|
||||
explainLevel?: 'default' | 'terse';
|
||||
respectDetection?: boolean;
|
||||
/** A detection result the caller already read; wins over respectDetection so one read serves identity and render. */
|
||||
gbrainDetected?: boolean;
|
||||
log?: (message: string) => void;
|
||||
planMode?: boolean;
|
||||
/** Module loadout (scripts/modules.ts selectModules); null/undefined = `full`. */
|
||||
@@ -94,7 +96,7 @@ export interface GenerationResult {
|
||||
}
|
||||
|
||||
/** Canonical generation never reads local detection state unless opted in. */
|
||||
function loadGbrainOverride(respectDetection: boolean): boolean {
|
||||
export function loadGbrainOverride(respectDetection: boolean): boolean {
|
||||
if (!respectDetection) return false;
|
||||
const stateDir = resolveStateRoot();
|
||||
try {
|
||||
@@ -989,7 +991,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
|
||||
model: settings.model ?? null,
|
||||
catalogMode: settings.catalogMode ?? 'trim',
|
||||
explainLevel: settings.explainLevel ?? 'default',
|
||||
gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
|
||||
gbrainDetected: settings.gbrainDetected ?? loadGbrainOverride(settings.respectDetection ?? false),
|
||||
planMode: settings.planMode ?? true,
|
||||
modules: settings.modules ?? null,
|
||||
};
|
||||
|
||||
@@ -13,21 +13,21 @@
|
||||
"files": {
|
||||
"scripts/lib/paid-cases.ts": {"blob":"f862cf5241580c7593b9ee62eacf5ef046248d26","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"},
|
||||
"scripts/lib/paid-plan.ts": {"blob":"e8ce3cb98474dded38c5128a631d283b0ca35b63","since":6,"nonBehavioral":"F1 rule-16 flaky rerun: report aggregation, rerun planning and the gate slice exit code; no case's execution, trial outcome, trial record or panel verdict changes, so no pass-rate series resets"},
|
||||
"scripts/lib/paid-report.ts": {"blob":"224dc5e8ba23a3589a37bad94db72ed1ca3c7c77","since":6,"nonBehavioral":"a case shard's result records are attributed to its one case: the trial's cost_usd and cost_known come from them, and a failed trial's failure_class, exit_reason and error come from its record instead of a bare assertion with the JUnit message, so a red whose record says infra or timeout is classed by that record (INFRA instead of VERDICT: rerun and bisect eligibility, not a pass rate); no case's outcome or selection changes and pass rates count outcomes only, so no pass-rate series resets"},
|
||||
"scripts/lib/paid-report.ts": {"blob":"e0d18da4ea02ac443835a68992abeea2b47f22fc","since":6,"nonBehavioral":"the headline adds only streamed session cost; a billed session's cost is already in its collector total; cache writes without a 5m key fall back to the total minus 1h; cost fields only"},
|
||||
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
|
||||
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
|
||||
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"44ae88bebf8f716f70a3bd836c8bedb03e6653e8","since":6,"nonBehavioral":"FREE_ONLY_PR_FILES gains scripts/lib/shard-jobs.ts (free-runner module) and .github/workflows/windows-smoke.yml (a workflow no paid case reads); both change only which PR paths select paid cases, never how a case runs or is judged"},
|
||||
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
|
||||
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
|
||||
"test/helpers/agent-sdk-runner.ts": {"blob":"d66dac458b8c13250973e550edf8087d4044cd0b","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets. Also: runAgentSdkTest now validates systemPrompt before starting; every live caller already passed the claude_code preset, so the prompt the model sees is unchanged and no verdict can change"},
|
||||
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
|
||||
"test/helpers/claude-pty-runner.ts": {"blob":"ce7a5eaa66e502a052ea9c53348dd2ab25db6148","since":6,"nonBehavioral":"Removes the plan-design 0D focus picker from the shared harness; floor.ts gains an optional pickSetupChoice hook that no other caller passes, so no other case's input or verdict can change. plan-design-finding-floor, the only case whose seed raises that menu, passes the picker from its owned fixture test/fixtures/design-floor-focus-picker.ts, so its owned bytes start its own new series."},
|
||||
"test/helpers/codex-host-runner.ts": {"blob":"bff225017e235f0be6ece29f04971ac04b95b544","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"},
|
||||
"test/helpers/codex-session-runner.ts": {"blob":"c040ebbfa815b8cce19ef80c6133bd5a5474149a","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"},
|
||||
"test/helpers/e2e-gate.ts": {"blob":"efb7e197dd5f66c3ad0e34074b14e640754f7a45","since":6},
|
||||
"test/helpers/e2e-helpers.ts": {"blob":"6ee6937e6c3ca081c77131b5a755278c7b714834","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"},
|
||||
"test/helpers/e2e-helpers.ts": {"blob":"91996ff2bc303ee29f4e6f1705d1468b2536b32e","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets"},
|
||||
"test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":6},
|
||||
"test/helpers/eval-store.ts": {"blob":"a50ea2c28f508ad9f35ea9353dd2f502ee0dcfc2","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"test/helpers/eval-store.ts": {"blob":"4085ad18cd99698f675deadad2a76a796427b693","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets"},
|
||||
"test/helpers/hermetic-env.ts": {"blob":"cc072895461dc52ea317d1beff72121342420d30","since":6},
|
||||
"test/helpers/llm-judge.ts": {"blob":"ff32d03f32e4af76c6c7314a79cd1b35263ed0dd","since":6,"nonBehavioral":"llm-judge.ts adds a pass-through byte counter (bytesReceived, lastByteMs) to the aborted-judge diagnostic; the request, response bytes, deadline and scoring are unchanged. GSTA-224 B2 (#3122): skill-fixture.ts follows review's renamed Step 2.5 heading so the review E2E cases extract the same section (now the skill's own reworded bytes); llm-judge.ts changes one bullet of the cross-skill consistency judge's intended architecture to name review-bot-history.md beside the older greptile-history.md name, so its calibration corpus (greptile-history inputs) stays valid. No other case's input or verdict can change; the cross-skill judge's judged files changed in this PR, so its owned inputs start its own series"},
|
||||
"test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":6},
|
||||
@@ -38,16 +38,17 @@
|
||||
"test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":6},
|
||||
"test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":6},
|
||||
"test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":6},
|
||||
"test/helpers/pty/launch.ts": {"blob":"e4413de380b799659170e7da248d51afda3b0e3f","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"test/helpers/pty/launch.ts": {"blob":"4f0731ef39bc3a6a7c5c592ce98690a24cb1ed7f","since":6,"nonBehavioral":"PTY transcript pricing finds the session by the caller's --session-id or the one new transcript for the launch cwd; the launcher adds no argument; native AUQ capture opts out; ledger cost fields only, no prompt, argv, answer, timeout or verdict changes"},
|
||||
"test/helpers/pty/plan-native.ts": {"blob":"ddba9cb68c7c0da935cff7c1cea258dee2dff740","since":6},
|
||||
"test/helpers/pty/runners/counting.ts": {"blob":"8c5c05e54abf961259d34d9c3d77db67138d7794","since":6},
|
||||
"test/helpers/pty/runners/floor.ts": {"blob":"a9938cb98f285474ebc4860cd61012504a36466e","since":6,"nonBehavioral":"The PTY floor and observation runners end a plan-mode turn at an idle prompt (the plan-mode footer without its running 'esc to interrupt' segment, after a 10 s settle) instead of waiting out the deadline. The floor runner exits as api_error only after the idle screen with its error panel held still for 10 s and every tick's question and assessment steps found nothing to credit (the transcript is re-read each tick), so a native or prose question already on screen is credited as before, even when its transcript line lands after the screen draws done (test/helpers/claude-pty-runner.runners.unit.test.ts, the prose-question-before-panel case). With no qualifying question an idle turn stays idle, so a trial that failed at the deadline as observer_timeout now fails earlier as api_error. An observation returns what the deadline would have returned. A running footer, extra footer text and a cut footer still wait for the deadline. No pass becomes a fail or a fail a pass. The session ledger may now record failure_cause api_error or refusal instead of observer_timeout or session_timeout for a plan-mode session whose last idle turn shows a panel; failure_class, the gate red class and pass rates are unchanged, so no pass-rate series resets"},
|
||||
"test/helpers/pty/runners/observation.ts": {"blob":"1c2b75fbb984708725abaa8939125f7cab969f9f","since":6},
|
||||
"test/helpers/pty/screen.ts": {"blob":"1384f765e48e511d5f843df2660b2ec70bbcb6b2","since":6,"nonBehavioral":"The PTY floor and observation runners end a plan-mode turn at an idle prompt (the plan-mode footer without its running 'esc to interrupt' segment, after a 10 s settle) instead of waiting out the deadline. The floor runner exits as api_error only after the idle screen with its error panel held still for 10 s and every tick's question and assessment steps found nothing to credit (the transcript is re-read each tick), so a native or prose question already on screen is credited as before, even when its transcript line lands after the screen draws done (test/helpers/claude-pty-runner.runners.unit.test.ts, the prose-question-before-panel case). With no qualifying question an idle turn stays idle, so a trial that failed at the deadline as observer_timeout now fails earlier as api_error. An observation returns what the deadline would have returned. A running footer, extra footer text and a cut footer still wait for the deadline. No pass becomes a fail or a fail a pass. The session ledger may now record failure_cause api_error or refusal instead of observer_timeout or session_timeout for a plan-mode session whose last idle turn shows a panel; failure_class, the gate red class and pass rates are unchanged, so no pass-rate series resets"},
|
||||
"test/helpers/pty/session.ts": {"blob":"54fe04b61d953e4e55e2fc251532c04a49fab66f","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"test/helpers/pty/session.ts": {"blob":"8fd1749a62f24dc37ae186c8a21c2165f7f7645e","since":6,"nonBehavioral":"PTY sessions price their ledger row (streamed_cost_usd) from the session transcript found by the caller's --session-id or the one new transcript for the launch cwd; ledger cost fields only, no prompt, argv, answer, timeout or verdict changes"},
|
||||
"test/helpers/pty/transcript-cost.ts": {"blob":"08f7ccbdbd9d71a5dc86edd2f8fced423a78ad9a","since":6,"nonBehavioral":"PTY transcript pricing finds the session by the caller's --session-id or the one new transcript for the launch cwd; the launcher adds no argument; native AUQ capture opts out; ledger cost fields only, no prompt, argv, answer, timeout or verdict changes"},
|
||||
"test/helpers/session-drain-policy.ts": {"blob":"6211239dd0485e6495a700b824a7e0e61d1bcd97","since":6},
|
||||
"test/helpers/session-ledger.ts": {"blob":"a10bf28bf100f135b93c73818670dd147253cdd3","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"test/helpers/session-runner.ts": {"blob":"cc65a66adafa9c053e3da3c6f17b5ac86c0eade7","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"},
|
||||
"test/helpers/session-ledger.ts": {"blob":"c99baaaa0a77a5a72384493fd8a85a4253ecb05d","since":6,"nonBehavioral":"PTY sessions price their ledger row (streamed_cost_usd) from the session transcript found by the caller's --session-id or the one new transcript for the launch cwd; ledger cost fields only, no prompt, argv, answer, timeout or verdict changes"},
|
||||
"test/helpers/session-runner.ts": {"blob":"c73b2596d9eb5377530a6bf1d284e0b6ea1556a0","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets"},
|
||||
"test/helpers/skill-fixture.ts": {"blob":"ce7609eacc7a8265a4a211358d1b4fb49ea85bc5","since":6,"nonBehavioral":"GSTA-224 B2 (#3122): skill-fixture.ts follows review's renamed Step 2.5 heading ('Check for review bot comments') so the review E2E cases extract the same section, now the skill's own reworded bytes; llm-judge.ts updates only the cross-skill consistency judge's intended architecture to the renamed review-bot-history.md and its record helper. No other case's input or verdict can change; the cross-skill judge's judged files changed in this PR, so its owned inputs start its own new series"},
|
||||
"test/helpers/skill-startup.ts": {"blob":"a8d21e3817c3f35a1d06af325c92fde7d420e377","since":6,"nonBehavioral":"readSkillEffective also appends a variant's added ### subsections (office-hours' Open-ended questions); readSkillStartup, readSharedSections and readSkillEffective return bytes identical to the pinned helper for every tier 2+ render without added subsections (735 across all hosts) and for office-hours' SKILL.md before this PR, so office-hours' fixture input changes only because its prompt changed in this PR, not because the helper did"},
|
||||
"test/helpers/test-selection.ts": {"blob":"e1b54ad5e3d3eadd8027de1b44e57554d6d99a41","since":6,"nonBehavioral":"selection only: fragment exemption narrowed to .md files directly in the fragments dir; changed-file lists include a rename's old path; no case prompt, fixture, judge or verdict logic changes (GST-400)"},
|
||||
|
||||
@@ -9,7 +9,7 @@ import { STALL_WINDOW_MS } from '../../test/helpers/eval-budgets';
|
||||
import {
|
||||
isFinalizedEvalResultFile, credentialRejection, failureClassOf, panelVerdict, sanitizeTrialError, formatTrialOutcomes, TRIAL_OUTCOME_SCHEMA, TRIAL_OUTCOMES_FILE,
|
||||
type EvalCaseKind, type PanelVerdict, type TrialFailureClass, type TrialOutcome, type TrialOutcomeRecord,
|
||||
trialCostKnown, trialFailureFields, trialSessions, type TrialFailureDetail,
|
||||
trialFailureFields, trialSessions, type TrialFailureDetail,
|
||||
} from '../../test/helpers/eval-store';
|
||||
import { readSessionLedger, type SessionLedgerRow } from '../../test/helpers/session-ledger';
|
||||
import { publishedFence, sanitizeFixedFenceLine } from './published-text';
|
||||
@@ -230,6 +230,8 @@ export interface JUnitCensus {
|
||||
deselected: Map<string, number>;
|
||||
/** Per shard key: skip reason -> skipped testcase count. */
|
||||
shardSkipReasons: Map<string, Map<string, number>>;
|
||||
/** Streamed session cost (sessions without a terminal result), which no collector record carries; the headline adds it to the collectors' total_cost_usd. */
|
||||
ledgerCostUsd: number;
|
||||
/** `<shard key> :: <test name> (<outcome>)` for every testcase no rule attributes. */
|
||||
unattributed: string[];
|
||||
}
|
||||
@@ -241,6 +243,24 @@ function caseSessions(rows: SessionLedgerRow[], id: string, onlyCase: boolean):
|
||||
return rows.filter(row => row.case === id || (!row.case && (onlyCase || row.test_name === id || row.test_name === CASE_TEST_NAMES[id])));
|
||||
}
|
||||
|
||||
/** Cases that run no model (a binary data flow): with no record and no session they cost a known $0. test/paid-report-cost.test.ts pins each body model-free. */
|
||||
export const MODEL_FREE_CASES: ReadonlySet<string> = new Set(['timeline-event-flow']);
|
||||
|
||||
/**
|
||||
* A trial's cost: its records' sum plus what no record carries, a session without a terminal result priced from its
|
||||
* streamed usage (streamed_cost_usd). With no record, a billed session counts its own cost_usd (Codex). Unknown when any
|
||||
* record or session cost is; a listed model-free case with neither record nor session is a known $0.
|
||||
*/
|
||||
export function caseCost(id: string, records: any[], sessions: SessionLedgerRow[]): { cost_usd: number; cost_known?: false } {
|
||||
if (!records.length && !sessions.length) return MODEL_FREE_CASES.has(id) ? { cost_usd: 0 } : { cost_usd: 0, cost_known: false };
|
||||
const unrecorded = sessions.map(s => s.billed !== false ? (records.length ? 0 : s.cost_usd) : s.streamed_cost_usd);
|
||||
const known = records.every(r => r?.cost_known !== false) && unrecorded.every(c => typeof c === 'number' && Number.isFinite(c));
|
||||
const total = records.reduce((sum, r) => sum + (Number(r?.cost_usd) || 0), 0) + knownSum(unrecorded);
|
||||
return { cost_usd: Math.round(total * 100) / 100, ...(known ? {} : { cost_known: false as const }) };
|
||||
}
|
||||
|
||||
const knownSum = (costs: Array<number | undefined>) => costs.reduce<number>((sum, c) => sum + (typeof c === 'number' && Number.isFinite(c) ? c : 0), 0);
|
||||
|
||||
/** The artifact holding a slice's shards: the downloaded directory's own name when it is one. */
|
||||
/** The red-census guide the report and every eval:pass-rates view link. */
|
||||
export const CENSUS_RED_GUIDE = 'docs/evals/census-red.md';
|
||||
@@ -252,7 +272,8 @@ export function sliceArtifactName(root: string, slice: number, attempt: number):
|
||||
|
||||
export function junitCensus(manifest: PaidRunManifest, artifacts: ReportArtifact[], recordsByShard: Map<string, any[]>,
|
||||
base: Record<string, unknown>, cliVersion?: string): JUnitCensus {
|
||||
const census: JUnitCensus = { ruleCases: [], history: [], failedShards: new Set(), skipped: [], deselected: new Map(), shardSkipReasons: new Map(), unattributed: [] };
|
||||
const census: JUnitCensus = { ruleCases: [], history: [], failedShards: new Set(), skipped: [], deselected: new Map(), shardSkipReasons: new Map(), unattributed: [],
|
||||
ledgerCostUsd: 0 };
|
||||
const entries = new Map(manifest.entries.map(entry => [normalizeRelativePath(entry.file), entry]));
|
||||
for (const { root, result } of artifacts) {
|
||||
for (const outcome of result.outcomes) {
|
||||
@@ -277,11 +298,15 @@ export function junitCensus(manifest: PaidRunManifest, artifacts: ReportArtifact
|
||||
const caseOutcome: TrialOutcome = failed ? 'failed' : cases.some(tc => tc.outcome === 'passed') ? 'passed' : 'skipped';
|
||||
// A record is named by its case id or carries case_id; an E2E record named by its display label ("/qa fix loop") is
|
||||
// the shard's only case's when the shard ran one case, else it stays unattributed and the trial's cost unknown.
|
||||
const mine = records.filter((r: any) => r?.name === id || r?.case_id === id || (byCase.size === 1 && !r?.case_id));
|
||||
const failedRecord = mine.find((r: any) => r.passed === false);
|
||||
// A display-label record whose runSkillTest testName is this case (cost_case_id) adds its cost only: the failed record stays the named one.
|
||||
const named = records.filter((r: any) => r?.name === id || r?.case_id === id || (byCase.size === 1 && !r?.case_id));
|
||||
const mine = [...named, ...records.filter((r: any) => r?.cost_case_id === id && !named.includes(r) && !byCase.has(r?.name))];
|
||||
const failedRecord = named.find((r: any) => r.passed === false);
|
||||
const recorded: TrialFailureClass | undefined = !failed ? undefined
|
||||
: failed.failureType === 'TimeoutError' ? 'timeout' : failedRecord ? failureClassOf(failedRecord) : 'assertion';
|
||||
const sessions = caseSessions(ledger, id, byCase.size === 1);
|
||||
const cost = caseCost(id, mine, sessions);
|
||||
census.ledgerCostUsd += knownSum(sessions.map(s => s.billed === false ? s.streamed_cost_usd : 0));
|
||||
const credentials = failed && recorded !== 'contract' ? credentialRejection({ error: `${failedRecord?.error ?? ''}\n${failed.message ?? ''}`, sessions }) : undefined;
|
||||
const failureClass = credentials ? 'infra' : recorded;
|
||||
const rawError = credentials ? `credentials rejected: ${credentials}` : failedRecord?.error ?? failed?.message;
|
||||
@@ -295,7 +320,7 @@ export function junitCensus(manifest: PaidRunManifest, artifacts: ReportArtifact
|
||||
census.skipped.push(`◌ ${id} ${key} (${cases.map(tc => tc.name).join('; ')}): ${RUNTIME_SKIP_REASON}`);
|
||||
}
|
||||
census.ruleCases.push({ id, kind, outcome: caseOutcome, key, slice: result.sliceIndex, ...(failureClass ? { failureClass } : {}), ...(error && failed ? { error } : {}),
|
||||
costUsd: Math.round(mine.reduce((sum: number, r: any) => sum + (Number(r.cost_usd) || 0), 0) * 100) / 100, costKnown: trialCostKnown(mine, sessions).cost_known !== false,
|
||||
costUsd: cost.cost_usd, costKnown: cost.cost_known !== false,
|
||||
...(failed ? { line: `✗ ${id} ${kind} FAIL ${failureClass}${failedRecord?.exit_reason === 'timeout' && failedRecord?.timeout_at_turn !== undefined ? ` at turn ${failedRecord.timeout_at_turn}` : ''}${error ? ` — ${error}` : ''}`
|
||||
+ `${diagnosisText(diagnosis, error)} [slice ${result.sliceIndex}, attempt ${result.attempt ?? 1}]`
|
||||
+ `${evidencePointer(sliceArtifactName(root, result.sliceIndex, result.attempt ?? 1), key, typeof base.run_id === 'string' ? base.run_id : undefined, id)}`
|
||||
@@ -303,8 +328,8 @@ export function junitCensus(manifest: PaidRunManifest, artifacts: ReportArtifact
|
||||
census.history.push({ ...base, case: id, file: shardFile(key), kind, trial: 1, panel: { n: 1, k: 1 }, outcome: caseOutcome,
|
||||
...(failureClass ? { failure_class: failureClass } : {}), ...(failedRecord?.exit_reason ? { exit_reason: String(failedRecord.exit_reason) } : {}),
|
||||
...(error && failed ? { error } : {}), ...diagnosis, ...trialSessions(sessions), duration_ms: Math.min(cases.reduce((sum, tc) => sum + tc.timeMs, 0), outcome.elapsedMs),
|
||||
cost_usd: Math.round(mine.reduce((sum: number, r: any) => sum + (Number(r.cost_usd) || 0), 0) * 100) / 100, ...trialCostKnown(mine, sessions),
|
||||
...(typeof mine[0]?.model === 'string' ? { model: mine[0].model } : {}), ...(cliVersion ? { cli_version: cliVersion } : {}),
|
||||
...cost,
|
||||
...(typeof named[0]?.model === 'string' ? { model: named[0].model } : {}), ...(cliVersion ? { cli_version: cliVersion } : {}),
|
||||
quarantined: false, execution: outcome.reused ? 'reused' : 'executed', source: 'junit' } as TrialOutcomeRecord);
|
||||
}
|
||||
census.shardSkipReasons.set(key, reasons);
|
||||
@@ -776,7 +801,7 @@ export function runPaidReport(reportDir: string, options: { writeDurations?: boo
|
||||
...(spend.executions ? { reruns: spend } : {}),
|
||||
actionRequired: verdict.problems.length,
|
||||
wallMs: wall.length ? Math.max(...wall.map(r => r.finishedAt!)) - Math.min(...wall.map(r => r.startedAt!)) : null,
|
||||
costUsd: Math.round(costUsd * 100) / 100,
|
||||
costUsd: Math.round((costUsd + census.ledgerCostUsd) * 100) / 100,
|
||||
redispatchEligible: red && infraOnly(verdict.problems),
|
||||
};
|
||||
const executed = history.filter(r => r.attempt === primary && r.execution === 'executed' && r.outcome !== 'skipped');
|
||||
|
||||
@@ -26,7 +26,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { runGeneration } from './gen-skill-docs';
|
||||
import { readConfigKey } from '../lib/state-root';
|
||||
import { loadGbrainOverride, runGeneration } from './gen-skill-docs';
|
||||
import { resolveModel } from './models';
|
||||
import { ModuleError, type ModuleSelection } from './modules';
|
||||
import { LOCK_FILE, danglingReferences } from './modules-render';
|
||||
@@ -148,6 +149,10 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
|
||||
const planMode = request.planMode ?? true;
|
||||
const model = request.boundModel ? resolveModel(request.boundModel) : null;
|
||||
const selection: ModuleSelection = { ...request.selection, boundModel: request.boundModel ?? undefined, fullRoot: request.boundModel ? request.fullRoot : undefined };
|
||||
const gbrainDetected = loadGbrainOverride(true);
|
||||
const disabledSkills = [...new Set((readConfigKey('disabled_skills') ?? '').replace(/["']/g, '').split(/[\s,]+/)
|
||||
.map(n => n.replace(/^\//, '')).filter(n => n !== 'gstack' && n !== 'gstack-upgrade')
|
||||
.map(n => n.replace(/^gstack-/, '')).filter(n => /^[a-z0-9-]+$/.test(n)))].sort();
|
||||
const identity = {
|
||||
loadout: selection.name,
|
||||
excluded: [...selection.excluded].sort(),
|
||||
@@ -157,6 +162,8 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
|
||||
host,
|
||||
overlay_model: model,
|
||||
plan_mode: planMode,
|
||||
gbrain_detected: gbrainDetected,
|
||||
disabled_skills: disabledSkills,
|
||||
source_tree: sourceTree(source),
|
||||
};
|
||||
const identitySha = sha256(JSON.stringify(identity));
|
||||
@@ -174,7 +181,7 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
|
||||
}
|
||||
const stage = `${final}.stage-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: request.fullRoot && request.fullRoot !== path.join(os.homedir(), '.claude/skills') ? path.join(request.fullRoot, 'gstack') : null, model, planMode, modules: selection });
|
||||
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: request.fullRoot && request.fullRoot !== path.join(os.homedir(), '.claude/skills') ? path.join(request.fullRoot, 'gstack') : null, model, planMode, modules: selection, gbrainDetected, disabledSkills });
|
||||
const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message);
|
||||
if (result.exitCode !== 0 || errors.length) {
|
||||
throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry');
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../lib/conductor-env-shim';
|
||||
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { readOverlay } from './resolvers/model-overlay';
|
||||
import { resolveClaudeBinary } from '../lib/claude-bin';
|
||||
import { sdkSystemPrompt } from '../test/helpers/sdk-system-prompt';
|
||||
|
||||
const USAGE = `Usage: bun run scripts/preflight-agent-sdk.ts [--live]
|
||||
|
||||
@@ -94,7 +95,7 @@ async function main() {
|
||||
prompt: 'say pong',
|
||||
options: {
|
||||
model: 'claude-opus-4-7',
|
||||
systemPrompt: '',
|
||||
...sdkSystemPrompt({ type: 'sdk-default', reason: 'connectivity probe with no tools: checks the event stream shape, not skill behavior' }),
|
||||
tools: [],
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
|
||||
@@ -85,6 +85,7 @@ function newShip(f: Fixture, sc: Scenario): { calls: Call[]; ending: string } {
|
||||
const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION;
|
||||
for (const d of plan.dispatches) calls.push({ role: d.role, name: d.name, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes });
|
||||
adversarialRan ||= plan.dispatches.some(d => d.role === 'adversarial-native');
|
||||
if (plan.next.step === 'voices') continue;
|
||||
const o = outcomes[sc][Math.min(i, outcomes[sc].length - 1)] ?? {};
|
||||
const last = plan.next.step === 'scoped' ? 'full' : plan.next.step;
|
||||
h = { fullPasses: h.fullPasses + (last === 'full' ? 1 : 0), last, ...o };
|
||||
|
||||
@@ -201,6 +201,24 @@ export function wallTimeoutForPackedShard(predictedMs: number, baseMs = DEFAULT_
|
||||
// tighter — it keeps the per-file floor the runner has always guaranteed.
|
||||
return Math.max(baseMs, Math.ceil(predictedMs * 3), fileCount * PER_FILE_WALL_MS);
|
||||
}
|
||||
/** The flaky-retry pass re-runs at most this many failing files, once, serially, in the same CI step. */
|
||||
export const FLAKY_RETRY_CAP = 5;
|
||||
/** No free-suite GitHub job gets a longer timeout; a plan whose shard would need more fails instead. */
|
||||
export const MAX_CI_JOB_TIMEOUT_MINUTES = 90;
|
||||
/**
|
||||
* The GitHub job timeout for one planned CI shard (#3850): the wall --ci-run
|
||||
* gives the shard, plus the flaky-retry pass's wall, plus the job's measured
|
||||
* setup and teardown. GitHub must never kill the job before the runner can
|
||||
* name a hung file and retry it; that kill uploads no result.
|
||||
*/
|
||||
export function ciShardJobTimeoutMinutes(shard: { shard: number; files: string[]; predictedMs: number }, setupSeconds: number): number {
|
||||
const runnerMs = wallTimeoutForPackedShard(shard.predictedMs, DEFAULT_WALL_TIMEOUT_MS, shard.files.length) + wallTimeoutForShard(FLAKY_RETRY_CAP);
|
||||
const minutes = Math.ceil((runnerMs / 1000 + setupSeconds) / 60);
|
||||
if (minutes > MAX_CI_JOB_TIMEOUT_MINUTES) {
|
||||
throw new Error(`FREE_JOB_TIMEOUT_OVER_CAP: CI shard ${shard.shard} needs a ${minutes}-minute job (runner wall plus retry plus ${setupSeconds} s setup), over the ${MAX_CI_JOB_TIMEOUT_MINUTES}-minute cap; plan more shards or a higher --shard-jobs instead of a longer job`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
/**
|
||||
* Full-suite parallelism: use all available CPUs, with a floor of one and a
|
||||
* per-platform cap (maxFullSuiteJobs). Shards stay serial internally; separate
|
||||
@@ -453,6 +471,16 @@ export function createFreeCiPlan(files: string[], count: number, durations: Reco
|
||||
return { ...body, id: planDigest(body) };
|
||||
}
|
||||
|
||||
/**
|
||||
* --ci-plan's stdout, the GitHub matrix. With --job-setup-seconds each entry
|
||||
* carries the shard's job timeout (ciShardJobTimeoutMinutes); without it, the
|
||||
* bare shard list the Windows lane reads.
|
||||
*/
|
||||
export function ciPlanMatrix(plan: FreeCiPlan, setupSeconds?: number): { shard: number[] } | { include: Array<{ shard: number; timeout_minutes: number }> } {
|
||||
if (setupSeconds === undefined) return { shard: plan.shards.map(shard => shard.shard) };
|
||||
return { include: plan.shards.map(shard => ({ shard: shard.shard, timeout_minutes: ciShardJobTimeoutMinutes(shard, setupSeconds) })) };
|
||||
}
|
||||
|
||||
export function validateFreeCiPlan(plan: FreeCiPlan, files: string[], revision: string): void {
|
||||
const { id, version, shards } = plan;
|
||||
if (version !== 1 || plan.revision !== revision || !Array.isArray(shards) || !shards.length
|
||||
@@ -575,6 +603,8 @@ type CliOptions = {
|
||||
jobs?: number;
|
||||
/** Set only by --shard-jobs (with --ci-plan); absent means k = 1 in every shard. */
|
||||
shardJobs?: number;
|
||||
/** Set only by --job-setup-seconds (with --ci-plan): the matrix then carries each shard's job timeout. */
|
||||
jobSetupSeconds?: number;
|
||||
};
|
||||
|
||||
export function parseCliOptions(argv: string[]): CliOptions {
|
||||
@@ -591,6 +621,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
let attributeHome = false;
|
||||
let jobs: number | undefined;
|
||||
let shardJobs: number | undefined;
|
||||
let jobSetupSeconds: number | undefined;
|
||||
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | null> = {
|
||||
ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null, filesFrom: null,
|
||||
};
|
||||
@@ -616,6 +647,11 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
'--files-from': pathFlag('--files-from', 'filesFrom'),
|
||||
'--jobs': (next) => { jobs = parseJobsFlag(next()); },
|
||||
'--shard-jobs': (next) => { shardJobs = parseJobsFlag(next(), '--shard-jobs'); },
|
||||
'--job-setup-seconds': (next) => {
|
||||
const value = next() ?? '';
|
||||
jobSetupSeconds = /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
if (!(jobSetupSeconds > 0 && jobSetupSeconds <= 3600)) throw new Error('--job-setup-seconds needs a whole number of seconds from 1 to 3600');
|
||||
},
|
||||
'--shards': (next) => {
|
||||
const value = next();
|
||||
if (!value) throw new Error('Missing value for --shards');
|
||||
@@ -641,8 +677,9 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results');
|
||||
if (jobs !== undefined && !paths.ciRun) rejectJobsWithoutCiRun();
|
||||
if (shardJobs !== undefined && !paths.ciPlan) rejectShardJobsWithoutCiPlan();
|
||||
if (jobSetupSeconds !== undefined && !paths.ciPlan) throw new Error('--job-setup-seconds applies only to --ci-plan');
|
||||
if (quick && (recordDurations || windowsOnly || shardIndex !== null)) throw new Error('--quick cannot change recording, Windows or shard selection');
|
||||
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }), ...(shardJobs === undefined ? {} : { shardJobs }) };
|
||||
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }), ...(shardJobs === undefined ? {} : { shardJobs }), ...(jobSetupSeconds === undefined ? {} : { jobSetupSeconds }) };
|
||||
}
|
||||
|
||||
const describeLines = (lines: string[]) => `${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` (+${lines.length - 5} more)` : ''}`;
|
||||
@@ -1891,7 +1928,6 @@ async function retryFailedFreeFiles(
|
||||
// Exists for syscall-supervised sandboxes (see fullSuiteJobs) where a run
|
||||
// lands 0-1 spurious browser-timing failures under an otherwise-green
|
||||
// suite. Capped so a genuinely broken tree never masquerades as flaky.
|
||||
const RETRY_CAP = 5;
|
||||
if (
|
||||
worst !== 0
|
||||
&& process.env.GSTACK_FREE_RETRY_FLAKY === '1'
|
||||
@@ -1906,7 +1942,7 @@ async function retryFailedFreeFiles(
|
||||
// mask the unattributable evidence as a FLAKY-PASS.
|
||||
const allAttributed = outcomes.every((o) => hasScopedFailureAttribution(o) && (o.status === 'passed'
|
||||
|| (o.failingFiles.length > 0 && o.unattributedFailures === 0)));
|
||||
if (allAttributed && flakyFiles.length > 0 && flakyFiles.length <= RETRY_CAP) {
|
||||
if (allAttributed && flakyFiles.length > 0 && flakyFiles.length <= FLAKY_RETRY_CAP) {
|
||||
console.log(`[test:free] flaky-retry: re-running ${flakyFiles.length} failing file(s) once, serially: ${flakyFiles.join(', ')}`);
|
||||
const retryOutcome = await runFreeShard(flakyFiles, totalShards + 1, totalShards + 1, {
|
||||
wallTimeoutMs: shardTimeout(flakyFiles.length),
|
||||
@@ -1942,7 +1978,7 @@ async function retryFailedFreeFiles(
|
||||
console.error('[test:free] flaky-retry FAILED — the failures reproduce serially; not flaky.');
|
||||
}
|
||||
} else {
|
||||
console.log(`[test:free] flaky-retry skipped: ${allAttributed ? `${flakyFiles.length} failing file(s) exceeds cap ${RETRY_CAP}` : 'failures not fully attributed'}.`);
|
||||
console.log(`[test:free] flaky-retry skipped: ${allAttributed ? `${flakyFiles.length} failing file(s) exceeds cap ${FLAKY_RETRY_CAP}` : 'failures not fully attributed'}.`);
|
||||
}
|
||||
}
|
||||
return { exitCode: worst, retry };
|
||||
@@ -1978,8 +2014,14 @@ async function main(): Promise<number> {
|
||||
warnUnseededFreeFiles(ciFiles, durations);
|
||||
const plan = createFreeCiPlan(ciFiles, options.shardCount, durations, revision, options.shardJobs);
|
||||
validateFreeCiPlan(plan, ciFiles, revision);
|
||||
const matrix = ciPlanMatrix(plan, options.jobSetupSeconds);
|
||||
if ('include' in matrix) {
|
||||
const line = `job timeout per shard (runner wall + flaky retry + ${options.jobSetupSeconds} s setup): ${matrix.include.map(entry => `${entry.shard}=${entry.timeout_minutes}m`).join(' ')}`;
|
||||
console.error(`[test:free] ${line}`);
|
||||
appendStepSummary(line);
|
||||
}
|
||||
writeJson(options.ciPlan, plan);
|
||||
console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) }));
|
||||
console.log(JSON.stringify(matrix));
|
||||
return 0;
|
||||
}
|
||||
const plan = JSON.parse(fs.readFileSync((options.ciRun ?? options.ciVerify)!, 'utf8')) as FreeCiPlan;
|
||||
|
||||
+4
-5
@@ -83,13 +83,12 @@ reader ran.
|
||||
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
|
||||
|
||||
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
|
||||
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
|
||||
`JUDGMENT: ok` before any fix), fixes and persistence in
|
||||
9.3, 9.4 and the repeat decision as written (`JUDGMENT: ok` before any fix), fixes and persistence in
|
||||
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
|
||||
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
|
||||
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from
|
||||
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (none:
|
||||
`ADVERSARIAL: skipped (review plan)`).
|
||||
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done` or `voices`: that program runs Step 11 from
|
||||
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
|
||||
prints `ADVERSARIAL: skipped (review plan)`).
|
||||
|
||||
## 5. Voices, bind and version: Steps 11, 11.5, 12
|
||||
|
||||
|
||||
+4
-5
@@ -81,13 +81,12 @@ reader ran.
|
||||
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
|
||||
|
||||
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
|
||||
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
|
||||
`JUDGMENT: ok` before any fix), fixes and persistence in
|
||||
9.3, 9.4 and the repeat decision as written (`JUDGMENT: ok` before any fix), fixes and persistence in
|
||||
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
|
||||
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
|
||||
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from
|
||||
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (none:
|
||||
`ADVERSARIAL: skipped (review plan)`).
|
||||
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done` or `voices`: that program runs Step 11 from
|
||||
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
|
||||
prints `ADVERSARIAL: skipped (review plan)`).
|
||||
|
||||
## 5. Voices, bind and version: Steps 11, 11.5, 12
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH
|
||||
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap,
|
||||
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
|
||||
and add `--reservation <id>` to the `--start adversarial-review` call below.
|
||||
After `NEXT: voices` (gate class: Step 11 before any stop), Finish the adversarial phase takes neither
|
||||
decision 2 nor 3: rerun Step 9's repeat decision with `--adversarial-ran` (a queued blocking finding as
|
||||
`--blocking-open`); its `NEXT:` (the stop, or the owner's granted fix-check) stands.
|
||||
|
||||
## Step 11: Adversarial review
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH
|
||||
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap,
|
||||
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
|
||||
and add `--reservation <id>` to the `--start adversarial-review` call below.
|
||||
After `NEXT: voices` (gate class: Step 11 before any stop), Finish the adversarial phase takes neither
|
||||
decision 2 nor 3: rerun Step 9's repeat decision with `--adversarial-ran` (a queued blocking finding as
|
||||
`--blocking-open`); its `NEXT:` (the stop, or the owner's granted fix-check) stands.
|
||||
|
||||
{{ADVERSARIAL_STEP}}
|
||||
|
||||
|
||||
@@ -822,8 +822,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan -
|
||||
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
|
||||
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
|
||||
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
|
||||
`--depth-floor <d>` when given), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
||||
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4.
|
||||
`--depth-floor <d>` when given, `--grant <approval id>` once the owner's grant of one scoped fix-check is
|
||||
in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
||||
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop.
|
||||
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
|
||||
Other scopes apply decisions 2–4 in order.
|
||||
|
||||
|
||||
@@ -231,8 +231,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan -
|
||||
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
|
||||
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
|
||||
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
|
||||
`--depth-floor <d>` when given), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
||||
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4.
|
||||
`--depth-floor <d>` when given, `--grant <approval id>` once the owner's grant of one scoped fix-check is
|
||||
in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
||||
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop.
|
||||
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
|
||||
Other scopes apply decisions 2–4 in order.
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@ for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR']) {
|
||||
// an unattended Capy session it never set up. The shard engine strips the same
|
||||
// list for the lanes; this reaches direct runs too.
|
||||
for (const name of CAPY_LAUNCH_MARKERS) delete process.env[name];
|
||||
// The variables gbrain reads to pick a brain (#3466). Tests spawn bins with
|
||||
// { ...process.env, HOME: <tmp> }; with no gbrain config under the temp HOME,
|
||||
// buildGbrainEnv passes an exported URL through and a real gbrain on PATH
|
||||
// writes test pages and sources into the developer's live brain.
|
||||
for (const name of ['GBRAIN_DATABASE_URL', 'GBRAIN_DIRECT_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_HOME']) {
|
||||
if (process.env[name] === undefined) continue;
|
||||
delete process.env[name];
|
||||
process.stderr.write(`test-setup: stripped inherited ${name}; tests must not reach a live gbrain\n`);
|
||||
}
|
||||
process.env.GSTACK_TEST_LEGACY_ROOT = path.join(os.tmpdir(), `gstack-test-legacy-root-${process.pid}-${Date.now()}-unused`);
|
||||
// Workflow leases (bin/gstack-lease) default to the installation's git dir;
|
||||
// for the dev checkout under test that is this repo's .git, and a live test
|
||||
|
||||
@@ -213,7 +213,7 @@ function makeStubProvider(config: StubConfig): QueryProvider {
|
||||
}
|
||||
|
||||
const BASE_OPTS = {
|
||||
systemPrompt: '',
|
||||
systemPrompt: { type: 'sdk-default', reason: 'stub queryProvider: no model reads the prompt' } as const,
|
||||
userPrompt: 'test prompt',
|
||||
workingDirectory: '/tmp/test-dir',
|
||||
maxRetries: 3,
|
||||
@@ -416,7 +416,7 @@ describe('runAgentSdkTest — options propagation', () => {
|
||||
calls: [],
|
||||
};
|
||||
await runAgentSdkTest({
|
||||
systemPrompt: 'you are a test overlay',
|
||||
systemPrompt: { type: 'custom', text: 'you are a test overlay', reason: 'checks that custom text reaches query()' },
|
||||
userPrompt: 'go',
|
||||
workingDirectory: '/tmp/spec-dir',
|
||||
model: 'claude-opus-4-7',
|
||||
@@ -451,7 +451,7 @@ describe('runAgentSdkTest — options propagation', () => {
|
||||
expect(opts.pathToClaudeCodeExecutable).toBe('/fake/path/claude');
|
||||
});
|
||||
|
||||
test('empty systemPrompt means no systemPrompt option passed', async () => {
|
||||
test('an sdk-default systemPrompt passes no systemPrompt option', async () => {
|
||||
freshSem();
|
||||
const stub: StubConfig = {
|
||||
streams: [[systemInit(), assistantTurn([{ type: 'text', text: 'ok' }]), resultSuccess()]],
|
||||
@@ -461,7 +461,6 @@ describe('runAgentSdkTest — options propagation', () => {
|
||||
...BASE_OPTS,
|
||||
queryProvider: makeStubProvider(stub),
|
||||
});
|
||||
// systemPrompt is undefined when empty string passed (so SDK uses no override)
|
||||
expect(stub.calls[0]!.options!.systemPrompt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -25,7 +25,7 @@
|
||||
"diagram": 4286,
|
||||
"document-generate": 6013,
|
||||
"document-release": 4454,
|
||||
"eval-plan": 5021,
|
||||
"eval-plan": 5107,
|
||||
"freeze": 1115,
|
||||
"gstack": 5350,
|
||||
"gstack-upgrade": 6297,
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('runFlaky', () => {
|
||||
expect(r.outcomes.map(o => [o.status, o.attempts.join(',')])).toEqual([['flaky-pass', 'fail,pass']]);
|
||||
expect(r.ledger).toHaveLength(1);
|
||||
expect(r.ledger[0]).toMatchObject({ kind: 'flaky-pass', id: 'a.test.ts::::b', rerun_by: 'gstack-flaky', attempts: ['fail', 'pass'], excerpt: ['boom'] });
|
||||
expect(renderResult(r, '/l.jsonl').at(-1)).toBe('FLAKY_RESULT: tests=2 failed=0 flaky=1 reruns=1 command_exit=1 verdict=pass ledger=/l.jsonl');
|
||||
expect(renderResult(r, '/l.jsonl').at(-1)).toBe('FLAKY_RESULT: tests=2 failed=0 flaky=1 quarantined=0 reruns=1 command_exit=1 verdict=pass ledger=/l.jsonl');
|
||||
expect(stepSummary(r)).toContain('a.test.ts::::b');
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
DEFAULT_WALL_TIMEOUT_MS, FLAKY_RETRY_CAP, ciPlanMatrix, collectFreeTestFiles, createFreeCiPlan,
|
||||
loadFreeTestDurations, wallTimeoutForPackedShard, wallTimeoutForShard,
|
||||
} from '../scripts/test-free-shards';
|
||||
|
||||
const WORKFLOW = path.resolve(import.meta.dir, '..', '.github', 'workflows', 'free-tests.yml');
|
||||
|
||||
@@ -235,4 +239,30 @@ describe('free-tests workflow wiring', () => {
|
||||
expect(source).toMatch(/permissions:\s*\n\s*contents:\s*read/);
|
||||
expect(source).toMatch(/persist-credentials:\s*false/);
|
||||
});
|
||||
test('every free-suite job timeout covers its shard\'s runner wall, flaky retry and setup (#3850)', () => {
|
||||
// GitHub must never kill a free-suite job before the runner's own wall can
|
||||
// name the hung file and its flaky-retry pass can run: that kill uploads no
|
||||
// result and leaves red CI with no attribution. The job timeout therefore
|
||||
// follows the real plan for the committed duration seed and the live
|
||||
// FREE_SUITE_SHARDS / FREE_SUITE_JOBS, never a hand-picked number.
|
||||
const workflow = Bun.YAML.parse(source) as any;
|
||||
const setupSeconds = Number(workflow.env.FREE_SUITE_SETUP_SECONDS);
|
||||
expect(Number.isInteger(setupSeconds) && setupSeconds > 0).toBe(true);
|
||||
const plan = createFreeCiPlan(collectFreeTestFiles(), Number(workflow.env.FREE_SUITE_SHARDS),
|
||||
loadFreeTestDurations() ?? {}, 'wiring', Number(workflow.env.FREE_SUITE_JOBS));
|
||||
const declared = workflow.jobs['free-suite']['timeout-minutes'];
|
||||
const emitted = ciPlanMatrix(plan, setupSeconds);
|
||||
if (!('include' in emitted)) throw new Error('the CI matrix carries no per-shard timeouts');
|
||||
const short = plan.shards.flatMap(shard => {
|
||||
const neededMs = wallTimeoutForPackedShard(shard.predictedMs, DEFAULT_WALL_TIMEOUT_MS, shard.files.length)
|
||||
+ wallTimeoutForShard(FLAKY_RETRY_CAP) + setupSeconds * 1000;
|
||||
const minutes = declared === '${{ matrix.timeout_minutes }}'
|
||||
? emitted.include.find(entry => entry.shard === shard.shard)?.timeout_minutes ?? 0
|
||||
: Number(declared);
|
||||
return minutes * 60_000 >= neededMs ? [] : [`shard ${shard.shard}: ${minutes} min < ${Math.ceil(neededMs / 1000)} s`];
|
||||
});
|
||||
expect(short).toEqual([]);
|
||||
const planStep = workflow.jobs['free-plan'].steps.find((step: any) => step.id === 'plan');
|
||||
expect(planStep.run).toContain('--job-setup-seconds "$FREE_SUITE_SETUP_SECONDS"');
|
||||
});
|
||||
});
|
||||
@@ -593,6 +593,82 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
expect(fs.existsSync(lite)).toBe(true);
|
||||
});
|
||||
|
||||
test('with no --model and no GSTACK_MODEL, the agent_model in the session preferences file picks the loadout (GST-982); GSTACK_MODEL still wins and a host preference serves full', () => {
|
||||
const f = fixture();
|
||||
script(path.join(f.root, 'bin/gstack-modules'), [
|
||||
'echo "modules $*" >> "$CAPY_LOG"',
|
||||
'model="${GSTACK_MODEL:-}" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done',
|
||||
'case "$model" in',
|
||||
' claude-opus-5-5) mkdir -p "$HOME/lite-0123456789ab"; echo "LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=0123456789ab reused=no"; echo "SKILL_ROOT: $HOME/lite-0123456789ab" ;;',
|
||||
' *) echo "LOADOUT: full model=none source=GSTACK_MODEL reason=unmapped"; echo "SKILL_ROOT: $full" ;;',
|
||||
'esac',
|
||||
]);
|
||||
script(path.join(f.root, 'bin/gstack-models'), [
|
||||
'echo "models $* prefs=${GSTACK_PREFERENCES_FILE:-}" >> "$CAPY_LOG"',
|
||||
'[ -z "${FAKE_BAD_MODEL:-}" ] || { echo "agent: agent_model $FAKE_BAD_MODEL is not a model"; exit 1; }',
|
||||
'[ -z "${FAKE_CONFIG_MODEL:-}" ] || { echo "agent: anthropic/$FAKE_CONFIG_MODEL (opus) - from agent_model in $HOME/.gstack/config.yaml, this machine"; exit 0; }',
|
||||
'case "$(sed -n "s/^agent_model: *//p" "${GSTACK_PREFERENCES_FILE:-/dev/null}" 2>/dev/null)" in',
|
||||
' opus) echo "agent: anthropic/claude-opus-5-5 (opus) - from agent_model in $GSTACK_PREFERENCES_FILE, the preferences file" ;;',
|
||||
' *) echo "agent: host - keep the running agent\'s own model and omit the model field (no preference set)" ;;',
|
||||
'esac',
|
||||
]);
|
||||
const prefs = path.join(f.home, 'drive-skill/preferences.yaml');
|
||||
write(prefs, 'agent_model: opus\n');
|
||||
const launchOf = (out: string) => out.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)![1];
|
||||
const opus = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs });
|
||||
expect(opus.status, opus.stdout + opus.stderr).toBe(0);
|
||||
expect(f.calls().filter(l => l.startsWith('modules '))).toEqual([`modules install --model claude-opus-5-5 --full-root ${f.home}/.claude/skills`]);
|
||||
expect(opus.stdout).toContain(`LOADOUT: lite model=claude-opus-5-5 source=preferences digest=0123456789ab reused=no\nSKILL_ROOT: ${f.home}/lite-0123456789ab\n`);
|
||||
expect(launchOf(opus.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.loadout-lite-0123456789ab.env'));
|
||||
expect(opus.stdout).toContain("LOADOUT_ASSUMED: model=claude-opus-5-5 is the owner's agent_model (preferences), not read from the session; a session on another model sets GSTACK_MODEL=<its id>\n");
|
||||
|
||||
const config = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, FAKE_CONFIG_MODEL: 'claude-opus-5-5' });
|
||||
expect(config.status, config.stdout + config.stderr).toBe(0);
|
||||
expect(config.stdout).toContain('LOADOUT: lite model=claude-opus-5-5 source=config digest=0123456789ab');
|
||||
expect(config.stdout).toContain("is the owner's agent_model (config)");
|
||||
|
||||
const modulesBefore = f.calls().filter(l => l.startsWith('modules ')).length;
|
||||
for (const evalEnv of [{ GSTACK_EVAL_DIR: path.join(f.home, 'evals') }, { EVALS_RUN_ID: 'r1' }] as Record<string, string>[]) {
|
||||
const ev = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, ...evalEnv });
|
||||
expect(ev.status, ev.stdout + ev.stderr).toBe(0);
|
||||
expect(ev.stdout).not.toMatch(/^(LOADOUT|LOADOUT_ASSUMED|SKILL_ROOT):/m);
|
||||
}
|
||||
expect(f.calls().filter(l => l.startsWith('modules ')).length).toBe(modulesBefore);
|
||||
|
||||
const bad = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, FAKE_BAD_MODEL: 'gpt-6.1-sol' });
|
||||
expect(bad.status, bad.stdout + bad.stderr).toBe(0);
|
||||
expect(bad.stdout).toContain('LOADOUT_FALLBACK: agent_model unreadable: agent: agent_model gpt-6.1-sol is not a model; serving full (docs/troubleshooting.md#loadout-fallback)\n');
|
||||
expect(bad.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
|
||||
expect(launchOf(bad.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.env'));
|
||||
expect(f.calls().filter(l => l.startsWith('modules ')).length).toBe(modulesBefore);
|
||||
|
||||
const env = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, GSTACK_MODEL: 'claude-sonnet-5-5' });
|
||||
expect(env.status, env.stdout + env.stderr).toBe(0);
|
||||
expect(f.calls().filter(l => l.startsWith('modules ')).at(-1)).toBe(`modules install --full-root ${f.home}/.claude/skills`);
|
||||
expect(env.stdout).toContain('LOADOUT: full model=none source=GSTACK_MODEL reason=unmapped\n');
|
||||
|
||||
write(prefs, 'agent_model: host\n');
|
||||
const host = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs });
|
||||
expect(host.status, host.stdout + host.stderr).toBe(0);
|
||||
expect(host.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
|
||||
expect(launchOf(host.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.env'));
|
||||
});
|
||||
|
||||
test('the first line of gstack-models resolve --role agent keeps the provider/model shape the installer reads (GST-982)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst982-'));
|
||||
try {
|
||||
for (const [pref, want] of [['opus', /^agent: anthropic\/claude-opus-[0-9a-z.-]+ \(opus\) /], ['host', /^agent: host /]] as const) {
|
||||
const file = path.join(dir, `${pref}.yaml`);
|
||||
fs.writeFileSync(file, `agent_model: ${pref}\n`);
|
||||
const out = spawnSync(path.join(REPO, 'bin/gstack-models'), ['resolve', '--role', 'agent'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_PREFERENCES_FILE: file, GSTACK_STATE_ROOT: dir, GSTACK_HOME: dir } });
|
||||
expect(out.status, out.stderr).toBe(0);
|
||||
expect(out.stdout.split('\n')[0]).toMatch(want);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the verdict file records checkout, HEAD, workflow, trailer and the Bun resolution, keyed by checkout and project; --check certifies without setup or downloads', () => {
|
||||
const f = fixture();
|
||||
const r = run(f, ['--for', 'autoplan']);
|
||||
|
||||
@@ -361,6 +361,26 @@ describe('ready', () => {
|
||||
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
|
||||
}, 180_000);
|
||||
|
||||
test('STAMP_VERSION is unstamped for a PR whose own diff leaves VERSION alone after main moved to a newer version, and the stamp for one that changes it (GST-770 follow-up)', () => {
|
||||
const repo = fixture();
|
||||
const head = makePr(repo, 'pr-a');
|
||||
repo.git('checkout', '-q', 'origin/main', '-B', 'main-moves');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 another PR landed');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', 'pr-a');
|
||||
const shim = (prHead: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [prHead]: green } });
|
||||
const forked = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, head)], shim(head));
|
||||
expect([forked.status, forked.err]).toEqual([EXIT.ok, '']);
|
||||
expect(forked.out.split('\n').slice(0, 4)).toEqual([`READY #1 ${head} STAMP_VERSION=unstamped`, 'pr: https://github.com/o/r/pull/1', `head: ${head}`, 'version: unstamped']);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.5.0 stamp');
|
||||
const stamped = repo.git('rev-parse', 'HEAD');
|
||||
const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, stamped)], shim(stamped));
|
||||
expect(ok.out.split('\n')[0]).toBe(`READY #1 ${stamped} STAMP_VERSION=1.2.5.0`);
|
||||
}, 180_000);
|
||||
|
||||
// Value: protects=a head whose required targeted reader is missing or failed is never READY (GST-966, gstack-evals#140);
|
||||
// fails_when=ready prints REVIEW_TARGETED verdict=missing or failed and still exits 0, or refuses a covered or unrequired head;
|
||||
// seam=bindShippedReceipt (ready, train build and land share it); no_claim=the ledger recording a clean reader as failed (GSTA-167)
|
||||
|
||||
@@ -37,6 +37,8 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { resolveClaudeBinary as resolveClaudeBinaryShared } from '../../lib/claude-bin';
|
||||
import { resolveEvalModel } from '../../lib/eval-model';
|
||||
import { streamedUsageCostUsd } from '../../lib/pricing';
|
||||
import { sdkSystemPrompt, type SystemPromptOption } from './sdk-system-prompt';
|
||||
import { SessionObserver, appendSessionLedger, sessionKey, type SessionEnd } from './session-ledger';
|
||||
import { hermeticChildEnv } from './hermetic-env';
|
||||
import type { SkillTestResult } from './session-runner';
|
||||
@@ -74,18 +76,15 @@ export interface AgentSdkResult {
|
||||
/** Signature matching `query()` from the SDK. DI hook for unit tests. */
|
||||
export type QueryProvider = typeof query;
|
||||
|
||||
/** Subset of SDK Options['systemPrompt'] we support. */
|
||||
export type SystemPromptOption =
|
||||
| string
|
||||
| { type: 'preset'; preset: 'claude_code'; append?: string; excludeDynamicSections?: boolean };
|
||||
export type { SystemPromptOption };
|
||||
|
||||
export interface RunAgentSdkOptions {
|
||||
/**
|
||||
* System prompt surface.
|
||||
* - bare string "" -> omit entirely (SDK default: no system prompt)
|
||||
* - bare string "...text..." -> REPLACE default with given text (use sparingly)
|
||||
* System prompt surface (test/helpers/sdk-system-prompt.ts).
|
||||
* - { type:'preset', preset:'claude_code' } -> use Claude Code default
|
||||
* - { type:'preset', preset:'claude_code', append: "..." } -> default + append
|
||||
* - { type:'sdk-default', reason } -> omit; the SDK runs its bare stub
|
||||
* - { type:'custom', text, reason } -> REPLACE default with given text
|
||||
*
|
||||
* For overlay-efficacy measurement, the preset+append pattern is the right
|
||||
* one: it measures "does adding overlay text to the REAL Claude Code system
|
||||
@@ -316,6 +315,7 @@ export async function runAgentSdkTest(
|
||||
opts: RunAgentSdkOptions,
|
||||
): Promise<AgentSdkResult> {
|
||||
const sem = getApiSemaphore();
|
||||
const systemPrompt = sdkSystemPrompt(opts.systemPrompt);
|
||||
const maxRetries = opts.maxRetries ?? 3;
|
||||
const queryImpl: QueryProvider = opts.queryProvider ?? query;
|
||||
// Default matches session-runner's frontier eval fallback. Tests that need a
|
||||
@@ -353,11 +353,12 @@ export async function runAgentSdkTest(
|
||||
const budget = typeof opts.sessionBudgetMs === 'function' ? opts.sessionBudgetMs() : opts.sessionBudgetMs;
|
||||
const aborted = controller.signal.aborted || opts.signal?.aborted === true;
|
||||
const structured = observer.verdict();
|
||||
const streamed = observer.billed ? undefined : streamedUsageCostUsd(assistantTurns, model);
|
||||
const end: SessionEnd = aborted ? (budget !== undefined && elapsed + 1000 >= budget ? 'session_timeout' : 'aborted') : structured?.end ?? fallback;
|
||||
appendSessionLedger({ key: sessionKey('agent-sdk', opts.testName), ...(opts.testName ? { test_name: opts.testName } : {}), runner: 'agent-sdk',
|
||||
started_at: startedAt, ...(budget !== undefined ? { budget_ms: budget } : {}), elapsed_ms: elapsed, end,
|
||||
...(end === 'session_timeout' ? { evidence: `armed ${budget}ms session timeout fired` } : structured && !aborted ? { evidence: structured.evidence } : {}),
|
||||
liveness: observer.summary(performance.now()), billed: observer.billed });
|
||||
liveness: observer.summary(performance.now()), billed: observer.billed, ...(streamed !== undefined ? { streamed_cost_usd: streamed } : {}) });
|
||||
};
|
||||
const controller = new AbortController();
|
||||
let activeQuery: ReturnType<QueryProvider> | undefined;
|
||||
@@ -444,11 +445,7 @@ export async function runAgentSdkTest(
|
||||
try { return await opts.canUseTool!(...args); } finally { observer.permission(-1); }
|
||||
} } : {}),
|
||||
};
|
||||
// Empty bare string means "omit entirely" (SDK runs with no override).
|
||||
// Any object or non-empty string is passed through.
|
||||
if (typeof opts.systemPrompt === 'object' || opts.systemPrompt !== '') {
|
||||
sdkOpts.systemPrompt = opts.systemPrompt;
|
||||
}
|
||||
Object.assign(sdkOpts, systemPrompt);
|
||||
|
||||
opts.signal?.throwIfAborted();
|
||||
const q = queryImpl({
|
||||
|
||||
@@ -313,7 +313,7 @@ Skip any system-audit / environment-setup / codebase-exploration steps. At the f
|
||||
session = await launchClaudePty({
|
||||
// Explicit tool approvals suffice. Bypass mode introduces a separate
|
||||
// safety-consent screen in a fresh CLI config; never accept that screen.
|
||||
cwd, model, seedSkills: false, permissionMode: 'default',
|
||||
cwd, model, seedSkills: false, permissionMode: 'default', priceTranscript: false,
|
||||
observeScreen: true, observeSetupQuestions: true,
|
||||
timeoutMs: Math.max(1, deadline - Date.now()),
|
||||
// Explicit availability as well as approval: no Bash/Agent/Skill or MCP.
|
||||
|
||||
@@ -76,3 +76,43 @@ process.stdout.write(JSON.stringify({ capture: resolveEvalModel('capture'),
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('launchClaudePty transcript pricing (GSTA-150 PR 3)', () => {
|
||||
test.skipIf(process.platform === 'win32')('prices the transcript this launch created for its cwd, without adding a launch argument; opting out prices nothing', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'pty-transcript-cost-'));
|
||||
try {
|
||||
const fake = join(dir, 'fake-claude');
|
||||
writeFileSync(fake, `#!${process.execPath}
|
||||
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||
const project = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', 'fake-project');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
fs.writeFileSync(path.join(project, crypto.randomUUID() + '.jsonl'), JSON.stringify({ type: 'assistant', cwd: process.cwd(),
|
||||
message: { id: 'msg_' + crypto.randomUUID(), model: 'claude-opus-5-5', usage: { input_tokens: 0, output_tokens: 1000 } } }) + '\\n');
|
||||
process.stdout.write('ARGV=' + JSON.stringify(process.argv.slice(2)) + '\\n');
|
||||
setInterval(() => {}, 1000);
|
||||
`, { mode: 0o755 });
|
||||
const runner = pathToFileURL(join(import.meta.dir, 'claude-pty-runner.ts')).href;
|
||||
const worker = join(dir, 'worker.ts');
|
||||
writeFileSync(worker, `import { launchClaudePty } from ${JSON.stringify(runner)};
|
||||
import * as fs from 'node:fs';
|
||||
const run = async (opts) => {
|
||||
const cwd = fs.mkdtempSync(${JSON.stringify(join(dir, 'cwd-'))});
|
||||
const session = await launchClaudePty({ cwd, timeoutMs: 5000, ...opts });
|
||||
try {
|
||||
await session.waitFor(/ARGV=\\[.*\\]/, { timeoutMs: 4000, pollMs: 20 });
|
||||
return { argv: JSON.parse(/ARGV=(\\[.*\\])/.exec(session.visibleText())[1]), cost: session.transcriptCostUsd?.() ?? null };
|
||||
} finally { await session.close(); }
|
||||
};
|
||||
const [priced, optedOut] = await Promise.all([run({}), run({ priceTranscript: false })]);
|
||||
process.stdout.write(JSON.stringify({ priced, optedOut }));
|
||||
`);
|
||||
const result = spawnSync(process.execPath, [worker], { cwd: join(import.meta.dir, '../..'), encoding: 'utf8', timeout: 30_000,
|
||||
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' } });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const { priced, optedOut } = JSON.parse(result.stdout.trim().split('\n').at(-1)!);
|
||||
expect(priced.cost).toBeCloseTo(0.02, 6);
|
||||
expect(optedOut.cost).toBeNull();
|
||||
expect(priced.argv).not.toContain('--session-id');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -274,6 +274,7 @@ export function recordE2E(
|
||||
duration_ms: result.duration,
|
||||
cost_usd: result.costEstimate.estimatedCost,
|
||||
...(result.costKnown === false ? { cost_known: false } : {}),
|
||||
...(result.testName && result.testName !== name && result.testName in E2E_TIERS ? { cost_case_id: result.testName } : {}),
|
||||
...(result.tokenUsage ? { token_usage: result.tokenUsage } : {}),
|
||||
transcript: result.transcript,
|
||||
output: result.output?.slice(0, 2000),
|
||||
|
||||
@@ -86,6 +86,9 @@ export interface EvalTestEntry {
|
||||
// TRIAL_ENV variables the paid runner sets on an isolated trial shard.
|
||||
/** Registry id (E2E_TIERS / LLM_JUDGE_TOUCHFILES key) this record belongs to. */
|
||||
case_id?: string;
|
||||
/** Registry id whose cost this record carries (runSkillTest's testName) when its name is a display label;
|
||||
* the report binds cost by it, never the failed record or its failure class. */
|
||||
cost_case_id?: string;
|
||||
kind?: EvalCaseKind;
|
||||
/** 1-based trial index within the case's panel. */
|
||||
trial?: number;
|
||||
|
||||
@@ -15,8 +15,12 @@ import { trustDialogInput } from '../pty-trust-dialog';
|
||||
import { resolveClaudeBinary } from './binary';
|
||||
import { createPtyScreen, screenPanelEnd, stripAnsi } from './screen';
|
||||
import { appendSessionLedger, sessionKey } from '../session-ledger';
|
||||
import { newTranscriptSessionId, pricedTranscript, ptyTranscriptCostUsd, transcriptFiles } from './transcript-cost';
|
||||
|
||||
export interface ClaudePtyOptions {
|
||||
/** Price this session's ledger row from the transcript Claude writes under the hermetic config dir (default true).
|
||||
* false for a caller that must never read model transcripts, such as the native AskUserQuestion capture. */
|
||||
priceTranscript?: boolean;
|
||||
/** Register the repo's shipped skills in the child's user scope via
|
||||
* hermeticSkillsConfigDir(). Required by any test that types a /skill
|
||||
* slash command; without it hermetic claude rejects the command as
|
||||
@@ -129,6 +133,9 @@ export interface ClaudePtySession {
|
||||
* the dir name ends in `/.claude` by contract).
|
||||
*/
|
||||
hermeticConfigDir: string | null;
|
||||
/** This session's cost from its transcript (the caller's --session-id, else the one transcript this launch created
|
||||
* for its cwd); undefined when unpriced, ambiguous or opted out. Never throws. */
|
||||
transcriptCostUsd?(): number | undefined;
|
||||
/** Owned HOME/.gstack created by the seeded launcher; absent for caller overrides. */
|
||||
hermeticSkillStateRoot?: string;
|
||||
/** Owned pre-tool identity record, removed by close(). */
|
||||
@@ -249,7 +256,8 @@ export async function launchClaudePty(
|
||||
recorded = true;
|
||||
const elapsed = performance.now() - startedMono;
|
||||
appendSessionLedger({ key: sessionKey('pty', undefined), runner: 'pty', started_at: startedAt, budget_ms: timeoutMs,
|
||||
elapsed_ms: elapsed, end: elapsed >= timeoutMs ? 'session_timeout' : 'completed', ...screenPanelEnd(handle.visibleText()), billed: false });
|
||||
elapsed_ms: elapsed, end: elapsed >= timeoutMs ? 'session_timeout' : 'completed', ...screenPanelEnd(handle.visibleText()), billed: false,
|
||||
...pricedTranscript(handle) });
|
||||
}
|
||||
return close();
|
||||
} });
|
||||
@@ -263,6 +271,8 @@ interface LaunchSetup {
|
||||
hermeticSkillStateRoot: string | undefined;
|
||||
autoplanArtifactStateRoot: string | undefined;
|
||||
autoplanEngTestPlanStateRoot: string | undefined;
|
||||
/** What prices the transcript: the caller's session id, else the transcripts present before spawn. */
|
||||
transcript: { sessionId: string | undefined; before: Set<string>; cwd: string } | undefined;
|
||||
}
|
||||
|
||||
function prepareLaunch(opts: ClaudePtyOptions): LaunchSetup {
|
||||
@@ -319,7 +329,13 @@ function prepareLaunch(opts: ClaudePtyOptions): LaunchSetup {
|
||||
|
||||
const autoplanEngTestPlanStateRoot = opts.approveAutoplanArtifactEdits && opts.autoplanArtifactState !== undefined
|
||||
? hermeticSkillStateRoot : undefined;
|
||||
return { args, childEnv, hermetic, hermeticSkillStateRoot, autoplanArtifactStateRoot, autoplanEngTestPlanStateRoot };
|
||||
const extra = opts.extraArgs ?? [];
|
||||
const pinned = extra.findIndex(arg => /^--session-id(=|$)/.test(arg));
|
||||
const sessionId = pinned < 0 ? undefined : extra[pinned].startsWith('--session-id=') ? extra[pinned].slice('--session-id='.length) : extra[pinned + 1];
|
||||
const resumes = extra.some(arg => /^(--resume|--continue|-r|-c)(=|$)/.test(arg));
|
||||
const transcript = hermetic && childEnv.CLAUDE_CONFIG_DIR && opts.priceTranscript !== false && !resumes
|
||||
? { sessionId, before: transcriptFiles(childEnv.CLAUDE_CONFIG_DIR), cwd: opts.cwd ?? process.cwd() } : undefined;
|
||||
return { args, childEnv, hermetic, hermeticSkillStateRoot, autoplanArtifactStateRoot, autoplanEngTestPlanStateRoot, transcript };
|
||||
}
|
||||
|
||||
interface LaunchRecorders {
|
||||
@@ -568,6 +584,11 @@ function sessionHandle(pty: PtyProcess, launch: LaunchSetup, wallTimer: ReturnTy
|
||||
exited: () => pty.exited,
|
||||
exitCode: () => pty.exitCode,
|
||||
hermeticConfigDir: launch.hermetic ? launch.childEnv.CLAUDE_CONFIG_DIR ?? null : null,
|
||||
transcriptCostUsd: () => {
|
||||
const { transcript, childEnv } = launch;
|
||||
return transcript && ptyTranscriptCostUsd(childEnv.CLAUDE_CONFIG_DIR,
|
||||
transcript.sessionId ?? newTranscriptSessionId(childEnv.CLAUDE_CONFIG_DIR, transcript.before, transcript.cwd));
|
||||
},
|
||||
hermeticSkillStateRoot: launch.hermeticSkillStateRoot,
|
||||
pendingPlanReadyFile: pendingExit?.file,
|
||||
pendingQuestionFile: pendingQuestion?.file,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { launchClaudePty, type ClaudePtyOptions, type ClaudePtySession } from './launch';
|
||||
import { screenPanelEnd } from './screen';
|
||||
import { appendSessionLedger, sessionKey, type SessionEnd } from '../session-ledger';
|
||||
import { pricedTranscript } from './transcript-cost';
|
||||
|
||||
/**
|
||||
* The runners' launch seam: how a session starts, and the clock the runner
|
||||
@@ -97,7 +98,7 @@ export async function runPtySession<R>(plan: PtySessionPlan<R>): Promise<R> {
|
||||
} finally {
|
||||
appendSessionLedger({ key: sessionKey('pty', undefined), runner: 'pty', started_at: startedAt,
|
||||
budget_ms: plan.budgetMs ?? plan.launch.timeoutMs ?? 240_000, elapsed_ms: plan.driver.monotonic() - started,
|
||||
end, ...safePanelEnd(session), billed: false });
|
||||
end, ...safePanelEnd(session), billed: false, ...pricedTranscript(session) });
|
||||
try {
|
||||
try { plan.beforeClose?.(session, failure); }
|
||||
catch (error) { if (!failure) { failure = { error }; throw error; } }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { streamedUsageCostUsd } from '../../../lib/pricing';
|
||||
|
||||
/** The session ledger's cost field for a PTY session: present only when its transcript prices. */
|
||||
export function pricedTranscript(session: { transcriptCostUsd?(): number | undefined } | undefined): { streamed_cost_usd?: number } {
|
||||
const cost = session?.transcriptCostUsd?.();
|
||||
return cost === undefined ? {} : { streamed_cost_usd: cost };
|
||||
}
|
||||
|
||||
/** Top-level session transcripts (`projects/<dir>/<id>.jsonl`) under a config dir; empty when unreadable. */
|
||||
export function transcriptFiles(configDir: string | undefined): Set<string> {
|
||||
if (!configDir) return new Set();
|
||||
try {
|
||||
const projects = path.join(configDir, 'projects');
|
||||
return new Set(fs.readdirSync(projects, { withFileTypes: true }).filter(dir => dir.isDirectory())
|
||||
.flatMap(dir => fs.readdirSync(path.join(projects, dir.name)).filter(f => f.endsWith('.jsonl')).map(f => path.join(projects, dir.name, f))));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session id of the one transcript that appeared since `before` and was written for `cwd`. Concurrent PTY tests in
|
||||
* one process share a hermetic config dir, so a launch claims only a new transcript for its own cwd; none or several
|
||||
* (two sessions in one cwd) is undefined, which leaves the cost unknown.
|
||||
*/
|
||||
export function newTranscriptSessionId(configDir: string | undefined, before: ReadonlySet<string>, cwd: string): string | undefined {
|
||||
try {
|
||||
const cwds = new Set([cwd, fs.realpathSync(cwd)]);
|
||||
const mine = [...transcriptFiles(configDir)].filter(file => !before.has(file) && fs.readFileSync(file, 'utf8').split('\n').some(line => {
|
||||
try {
|
||||
return cwds.has(JSON.parse(line)?.cwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return mine.length === 1 ? path.basename(mine[0], '.jsonl') : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one PTY session from the transcripts Claude Code wrote under its owned config dir:
|
||||
* `projects/<dir>/<sessionId>.jsonl` (inline sidechains included) and `projects/<dir>/<sessionId>/subagents/*.jsonl`,
|
||||
* rows deduplicated by message id. Undefined (cost-unknown, never $0) without a config dir, session id, transcript or
|
||||
* usage, when a message's model has no price row, or when any read fails: it runs in ledger writers before close and
|
||||
* cleanup, so it never throws.
|
||||
*/
|
||||
export function ptyTranscriptCostUsd(configDir: string | null | undefined, sessionId: string | undefined): number | undefined {
|
||||
if (!configDir || !sessionId) return undefined;
|
||||
try {
|
||||
const projects = path.join(configDir, 'projects');
|
||||
const files: string[] = [];
|
||||
for (const dir of fs.readdirSync(projects, { withFileTypes: true })) {
|
||||
if (!dir.isDirectory()) continue;
|
||||
const main = path.join(projects, dir.name, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(main)) files.push(main);
|
||||
const subagents = path.join(projects, dir.name, sessionId, 'subagents');
|
||||
if (fs.existsSync(subagents)) files.push(...fs.readdirSync(subagents).filter(f => f.endsWith('.jsonl')).map(f => path.join(subagents, f)));
|
||||
}
|
||||
const rows = files.flatMap(file => fs.readFileSync(file, 'utf8').split('\n').flatMap(line => {
|
||||
try {
|
||||
return [JSON.parse(line)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
return streamedUsageCostUsd(rows);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The system prompt every Agent SDK caller hands to Claude Code.
|
||||
*
|
||||
* Without `systemPrompt`, the SDK runs a bare stub instead of Claude Code's
|
||||
* own prompt, and the stub drops the "run /skill via Skill" line, so a skill
|
||||
* arm never learns how to load its skill. Callers pass the Claude Code preset,
|
||||
* or name why they don't: docs/TESTING_INTERNALS.md#agent-sdk-system-prompt.
|
||||
* test/sdk-system-prompt-tripwire.test.ts holds every caller to this.
|
||||
*/
|
||||
|
||||
export const CLAUDE_CODE_PRESET = { type: 'preset', preset: 'claude_code' } as const;
|
||||
|
||||
export type SystemPromptOption =
|
||||
| { type: 'preset'; preset: 'claude_code'; append?: string; excludeDynamicSections?: boolean }
|
||||
| { type: 'sdk-default'; reason: string }
|
||||
| { type: 'custom'; text: string; reason: string };
|
||||
|
||||
type SdkSystemPrompt = string | { type: 'preset'; preset: 'claude_code'; append?: string; excludeDynamicSections?: boolean };
|
||||
|
||||
/** The SDK options a caller's choice becomes; throws on a bare, unnamed or unknown prompt. */
|
||||
export function sdkSystemPrompt(option: SystemPromptOption): { systemPrompt?: SdkSystemPrompt } {
|
||||
const fix = 'pass CLAUDE_CODE_PRESET, or { type: "sdk-default" | "custom", reason } naming why not (docs/TESTING_INTERNALS.md#agent-sdk-system-prompt)';
|
||||
const o = option as unknown;
|
||||
if (!o || typeof o !== 'object') throw new Error(`systemPrompt must be an object, got ${JSON.stringify(o)}: ${fix}`);
|
||||
const p = o as Record<string, unknown>;
|
||||
if (p.type === 'preset' && p.preset === 'claude_code') return { systemPrompt: option as SdkSystemPrompt };
|
||||
if (p.type !== 'sdk-default' && p.type !== 'custom') throw new Error(`unknown systemPrompt ${JSON.stringify(o)}: ${fix}`);
|
||||
if (typeof p.reason !== 'string' || !p.reason.trim()) throw new Error(`systemPrompt type "${p.type}" needs a non-empty reason: ${fix}`);
|
||||
if (p.type === 'sdk-default') return {};
|
||||
if (typeof p.text !== 'string' || !p.text) throw new Error(`systemPrompt type "custom" needs its text: ${fix}`);
|
||||
return { systemPrompt: p.text };
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export interface SessionLedgerRow {
|
||||
liveness?: LivenessSummary;
|
||||
/** Whether the runner captured this session's billing (a terminal result with total_cost_usd). */
|
||||
billed?: boolean;
|
||||
/** A session without a terminal result (an aborted Agent SDK session, a PTY session): its assistant messages' usage at lib/pricing.ts rates, from the stream or the PTY transcript; absent when unpriced. */
|
||||
streamed_cost_usd?: number;
|
||||
/** Codex sessions: the model, its billed cost and token classes. */
|
||||
model?: string;
|
||||
cost_usd?: number;
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface SkillTestResult {
|
||||
tokenUsage?: CodexTokenUsage;
|
||||
/** Codex host: false when the session completed no turn or its model has no price row. */
|
||||
costKnown?: boolean;
|
||||
/** runSkillTest's testName: the registered case id the session ledger names, which recordE2E binds the record's cost to. */
|
||||
testName?: string;
|
||||
}
|
||||
|
||||
/** Local default startup grace: 90s covers observed API queue latency
|
||||
@@ -741,5 +743,5 @@ Before source Reads, use Bash to run exactly \`date -u +%Y-%m-%dT%H:%M:%SZ\`. Af
|
||||
turnsUsed,
|
||||
};
|
||||
|
||||
return { toolCalls, browseErrors, exitReason, duration, output: resultLine?.result || '', costEstimate, transcript, model, firstResponseMs, maxInterTurnMs };
|
||||
return { toolCalls, browseErrors, exitReason, duration, output: resultLine?.result || '', costEstimate, transcript, model, firstResponseMs, maxInterTurnMs, ...(testName ? { testName } : {}) };
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'test/helpers/ship-skip-actor.ts',
|
||||
'test/helpers/scratch-repo.ts',
|
||||
'test/skill-e2e-ship-skip.test.ts', 'test/helpers/shared-libs-eval-fixture.ts',
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/e2e-gate.ts', 'test/helpers/eval-budgets.ts'
|
||||
],
|
||||
// The frozen review scope's two-pass convergence: the generated Step 9.4 + repeat decision, the real
|
||||
@@ -50,24 +50,24 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'lib/review-evidence.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'test/helpers/ship-frozen-scope-actor.ts', 'test/helpers/scratch-repo.ts',
|
||||
'test/skill-e2e-ship-frozen-scope.test.ts', 'test/helpers/shared-libs-eval-fixture.ts',
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/e2e-gate.ts', 'test/helpers/eval-budgets.ts'
|
||||
],
|
||||
'investigate-owned-completion': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-completion.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'investigate-owned-abort': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'investigate-owned-ending-error': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'shared-libs-review-path-eligibility': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-review-index-flags': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-paths-max-turns-public.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-review-prior-coverage': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-codex-read-only': [ 'lib/outside-review-result.ts', 'lib/gate-outcomes.ts','deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/helpers/codex-session-runner.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/eval-budgets.ts', 'test/codex-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'hosts/codex.ts', 'hosts/define-host.ts', 'scripts/resolvers/constants.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'bin/gstack-codex-probe', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/outside-voice-steps.ts'],
|
||||
'investigate-owned-completion': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-completion.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'investigate-owned-abort': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'investigate-owned-ending-error': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'shared-libs-review-path-eligibility': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-review-index-flags': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-paths-max-turns-public.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-review-prior-coverage': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-paths.test.ts', 'test/helpers/shared-libs-path-fixture.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/fixtures/shared-libs-resolved-reads-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-codex-read-only': [ 'lib/outside-review-result.ts', 'lib/gate-outcomes.ts','deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/helpers/codex-session-runner.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/eval-budgets.ts', 'test/codex-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'hosts/codex.ts', 'hosts/define-host.ts', 'scripts/resolvers/constants.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'bin/gstack-codex-probe', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/outside-voice-steps.ts'],
|
||||
// Shared-code audit and scoped review lifecycle
|
||||
'shared-libs-read-only': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
||||
'shared-libs-unsupported-git': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
||||
'shared-libs-review-lifecycle': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts', 'test/fixtures/shared-libs-lifecycle-r59-stage-scope-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-review-revalidation': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/fixtures/shared-libs-review-start-public.json', 'test/fixtures/shared-libs-revalidation-max-turns-public.json', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts' ],
|
||||
'shared-libs-opportunity-judgment': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'test/helpers/skill-startup.ts'],
|
||||
'shared-libs-pr-coverage': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'test/helpers/skill-startup.ts'],
|
||||
'shared-libs-plan-callers': ['test/helpers/shared-libs-plan-actor.ts', 'scripts/resolvers/confidence.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'plan-eng-review/**', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/fixtures/plan-scope-recovery-av.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/helpers/skill-startup.ts'],
|
||||
'shared-libs-read-only': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
||||
'shared-libs-unsupported-git': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/helpers/shared-libs-path-fixture.ts'],
|
||||
'shared-libs-review-lifecycle': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts', 'test/fixtures/shared-libs-lifecycle-r59-stage-scope-public.json', 'test/helpers/shared-libs-review-start-evidence.ts'],
|
||||
'shared-libs-review-revalidation': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'review/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'lib/review-evidence.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'test/skill-e2e-shared-libs.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/helpers/shared-libs-review-start-evidence.ts', 'test/fixtures/shared-libs-review-start-public.json', 'test/fixtures/shared-libs-revalidation-max-turns-public.json', 'test/fixtures/shared-libs-index-flags-*.json', 'test/helpers/shared-libs-path-fixture.ts' ],
|
||||
'shared-libs-opportunity-judgment': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'test/helpers/skill-startup.ts'],
|
||||
'shared-libs-pr-coverage': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'test/helpers/skill-startup.ts'],
|
||||
'shared-libs-plan-callers': ['test/helpers/shared-libs-plan-actor.ts', 'scripts/resolvers/confidence.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'plan-eng-review/**', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/fixtures/plan-scope-recovery-av.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/helpers/skill-startup.ts'],
|
||||
'ship-docsync-missing-marker': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'],
|
||||
'ship-docsync-missing-asset': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'],
|
||||
'ship-docsync-launch-failure': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'],
|
||||
@@ -366,7 +366,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// Native question capture and interactive workflow probes.
|
||||
'auq-format-gate': [ 'ETHOS.md', 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/helpers/llm-judge.ts', 'test/skill-e2e-ask-user-question-format-compliance.test.ts',
|
||||
|
||||
'test/helpers/agent-sdk-runner.ts',
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts',
|
||||
'test/helpers/auq-native-capture.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/eval-store.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
|
||||
@@ -417,35 +417,35 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'tpa-apple-ban': [ 'scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts', 'test/helpers/third-party-actions.ts',
|
||||
'lib/eval-model.ts'
|
||||
],
|
||||
'ship-measure-seeded-flake': ['ship/**', 'scripts/ship-measure.ts', 'scripts/lib/measure-bar.ts', 'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/paid-select.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/shard-jobs.ts', 'bin/gstack-config', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-ship-measure-loop.test.ts', 'test/helpers/ship-measure-seeded-fixture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/skill-fixture.ts'],
|
||||
'ship-measure-seeded-flake': ['ship/**', 'scripts/ship-measure.ts', 'scripts/lib/measure-bar.ts', 'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/paid-select.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/shard-jobs.ts', 'bin/gstack-config', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-ship-measure-loop.test.ts', 'test/helpers/ship-measure-seeded-fixture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/skill-fixture.ts'],
|
||||
'ship-section-loading': [ 'ship/**', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-ship-section-loading.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'plan-ceo-section-loading': [ 'ETHOS.md',
|
||||
'test/fixtures/ceo-fill-lifetime.json', 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/helpers/ceo-section-loading-fixture.ts', 'test/helpers/ceo-stale-fill-decision.ts', 'test/skill-e2e-plan-ceo-review-section-loading.test.ts', 'test/fixtures/ceo-section-loading-l-report.md', 'test/fixtures/ceo-section-loading-q-report.md', 'test/fixtures/ceo-section-r-rejected-report.md', 'test/fixtures/ceo-section-s-trace-report.md', 'test/fixtures/ceo-section-u-report.md', 'test/fixtures/ceo-section-y-report.md', 'test/fixtures/ceo-section-aa-report.md', 'test/fixtures/sdk-stale-table-ad-v3.json', 'test/fixtures/sdk-ordering-ae.json', 'test/fixtures/sdk-columnar-af.json', 'test/fixtures/sdk-order-b-ag.json', 'test/fixtures/sdk-schedule-continuation-ah.json', 'test/fixtures/sdk-original-order-ai.json', 'test/fixtures/sdk-compact-sequence-aj.json',
|
||||
"test/fixtures/sdk-reported-coordination-ar.md", "test/fixtures/sdk-ordered-schedule-ar.md",
|
||||
'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
// Data-driven behavioral guard for the 'plan'/'prompt' carves: one case per
|
||||
// skill in test/carve-section-loading.test.ts (case-sharded, W5a). Each case
|
||||
// selects on its own skill directory; the registry, helper, harness and
|
||||
// sections.ts select every case.
|
||||
'carve-section-loading-browse': ['browse/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-codex': ['codex/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-design-consultation': ['design-consultation/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-design-html': ['design-html/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-design-shotgun': ['design-shotgun/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-document-release': ['document-release/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-benchmark': ['benchmark/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-context-restore': ['context-restore/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-investigate': ['investigate/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-land-and-deploy': ['land-and-deploy/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-plan-design-review': ['plan-design-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-plan-devex-review': ['plan-devex-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-plan-eng-review': ['plan-eng-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'scripts/resolvers/learnings.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'scripts/resolvers/testing.ts', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-qa': ['qa/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-retro': ['retro/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-review': ['review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-setup-gbrain': ['setup-gbrain/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-spec': ['spec/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-browse': ['browse/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-codex': ['codex/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-design-consultation': ['design-consultation/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-design-html': ['design-html/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-design-shotgun': ['design-shotgun/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-document-release': ['document-release/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-benchmark': ['benchmark/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-context-restore': ['context-restore/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-investigate': ['investigate/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-land-and-deploy': ['land-and-deploy/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-plan-design-review': ['plan-design-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-plan-devex-review': ['plan-devex-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-plan-eng-review': ['plan-eng-review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'scripts/resolvers/learnings.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'scripts/resolvers/testing.ts', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-qa': ['qa/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-retro': ['retro/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-review': ['review/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-setup-gbrain': ['setup-gbrain/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'carve-section-loading-spec': ['spec/**', 'setup', 'bin/gstack-bun-version.sh', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue', 'test/fixtures/autoplan-amend-input-77.json', 'test/fixtures/autoplan-phase-handoff-6714.json', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/carve-section-loading.test.ts', 'test/helpers/carve-section-case.ts', 'test/fixtures/design-html-section-complete.md', 'test/helpers/carve-plan-fixture.ts', 'test/fixtures/carve-existing-repository/**', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
|
||||
// Per-finding AskUserQuestion count + review-report-at-bottom assertion.
|
||||
// Each test drives its skill end-to-end; touchfiles include preamble +
|
||||
@@ -582,15 +582,15 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// Agent SDK. Gate-tier (deterministic stub server, fixed inputs); fires
|
||||
// when the skill template, the verify helper, the artifacts-init helper,
|
||||
// or the detect script changes.
|
||||
'setup-gbrain-remote': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/setup-gbrain-sandbox.ts', 'test/helpers/office-hours-attempt.ts', 'test/helpers/eval-store.ts', 'test/helpers/e2e-helpers.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'lib/eval-model.ts','setup-gbrain/sections/brain-init.md.tmpl', 'setup-gbrain/sections/claude-md-persist.md.tmpl', 'setup-gbrain/sections/manifest.json', 'test/helpers/setup-gbrain-fixture.ts', 'setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-artifacts-init', 'bin/gstack-gbrain-detect', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-setup-gbrain-remote.test.ts',
|
||||
'setup-gbrain-remote': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/helpers/setup-gbrain-sandbox.ts', 'test/helpers/office-hours-attempt.ts', 'test/helpers/eval-store.ts', 'test/helpers/e2e-helpers.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'lib/eval-model.ts','setup-gbrain/sections/brain-init.md.tmpl', 'setup-gbrain/sections/claude-md-persist.md.tmpl', 'setup-gbrain/sections/manifest.json', 'test/helpers/setup-gbrain-fixture.ts', 'setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-artifacts-init', 'bin/gstack-gbrain-detect', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/skill-e2e-setup-gbrain-remote.test.ts',
|
||||
'test/helpers/e2e-gate.ts'],
|
||||
'setup-gbrain-bad-token': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'setup-gbrain/sections/brain-init.md.tmpl', 'setup-gbrain/sections/manifest.json', 'test/helpers/setup-gbrain-fixture.ts', 'setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-setup-gbrain-bad-token.test.ts',
|
||||
'setup-gbrain-bad-token': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'setup-gbrain/sections/brain-init.md.tmpl', 'setup-gbrain/sections/manifest.json', 'test/helpers/setup-gbrain-fixture.ts', 'setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/skill-e2e-setup-gbrain-bad-token.test.ts',
|
||||
'test/helpers/setup-gbrain-sandbox.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/setup-gbrain-fixture-command.ts', 'bin/gstack-gbrain-detect', 'lib/gbrain-local-status.ts', 'lib/gbrain-exec.ts', 'bin/gstack-gbrain-lib.sh', 'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'test/helpers/office-hours-attempt.ts', 'test/helpers/e2e-gate.ts'],
|
||||
// v1.34.0.0 split-engine Path 4 + Step 4.5 Yes (local PGLite for code).
|
||||
// Periodic-tier per codex #12 (AgentSDK harness is non-deterministic).
|
||||
// Fires when the setup-gbrain template, install/verify/init helpers, or
|
||||
// the agent-sdk-runner harness changes.
|
||||
'setup-gbrain-path4-local-pglite': [ 'lib/memory-ingest-landing.ts', 'bin/gstack-memory-ingest.ts', 'bin/gstack-gbrain-sync.ts','bin/gstack-state-root.sh', 'lib/state-root.ts', 'setup-gbrain/sections/brain-init.md.tmpl', 'setup-gbrain/sections/claude-md-persist.md.tmpl', 'setup-gbrain/sections/manifest.json', 'test/helpers/setup-gbrain-fixture.ts', 'setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-gbrain-install', 'bin/gstack-gbrain-detect', 'lib/gbrain-local-status.ts', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-setup-gbrain-path4-local-pglite.test.ts',
|
||||
'setup-gbrain-path4-local-pglite': [ 'lib/memory-ingest-landing.ts', 'bin/gstack-memory-ingest.ts', 'bin/gstack-gbrain-sync.ts','bin/gstack-state-root.sh', 'lib/state-root.ts', 'setup-gbrain/sections/brain-init.md.tmpl', 'setup-gbrain/sections/claude-md-persist.md.tmpl', 'setup-gbrain/sections/manifest.json', 'test/helpers/setup-gbrain-fixture.ts', 'setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-gbrain-install', 'bin/gstack-gbrain-detect', 'lib/gbrain-local-status.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/skill-e2e-setup-gbrain-path4-local-pglite.test.ts',
|
||||
'test/helpers/setup-gbrain-sandbox.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/setup-gbrain-fixture-command.ts', 'lib/gbrain-exec.ts', 'bin/gstack-gbrain-lib.sh', 'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'test/helpers/office-hours-attempt.ts', 'test/helpers/e2e-gate.ts'],
|
||||
|
||||
// AskUserQuestion format regression (RECOMMENDATION + Completeness: N/10)
|
||||
@@ -654,15 +654,15 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// Ship
|
||||
'ship-managed-hook-refresh': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'bin/gstack-redact', 'bin/gstack-config', 'scripts/gen-skill-docs.ts',
|
||||
'test/helpers/ship-hook-actor.ts',
|
||||
'test/helpers/workflow-excerpt.ts', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-ship-hook-refresh.test.ts',
|
||||
'test/helpers/workflow-excerpt.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/skill-e2e-ship-hook-refresh.test.ts',
|
||||
'test/helpers/e2e-gate.ts'],
|
||||
'ship-unmanaged-hook-consent': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'bin/gstack-redact', 'bin/gstack-config', 'scripts/gen-skill-docs.ts',
|
||||
'test/helpers/ship-hook-actor.ts',
|
||||
'test/helpers/workflow-excerpt.ts', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-ship-hook-consent.test.ts',
|
||||
'test/helpers/workflow-excerpt.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/skill-e2e-ship-hook-consent.test.ts',
|
||||
'test/helpers/e2e-gate.ts'],
|
||||
'ship-local-hook-preservation': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'bin/gstack-redact', 'bin/gstack-config', 'scripts/gen-skill-docs.ts',
|
||||
'test/helpers/ship-hook-actor.ts',
|
||||
'test/helpers/workflow-excerpt.ts', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-ship-hook-consent.test.ts',
|
||||
'test/helpers/workflow-excerpt.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/skill-e2e-ship-hook-consent.test.ts',
|
||||
'test/helpers/e2e-gate.ts'],
|
||||
'ship-base-branch': [ 'bin/gstack-version-bump','bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'bin/gstack-repo-mode', 'test/skill-e2e-review-attribution.test.ts',
|
||||
'scripts/resolvers/testing.ts'
|
||||
@@ -741,11 +741,11 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
],
|
||||
// Formerly keyless periodic files (W1b): registered so an edit selects its own case instead of restoring the full PR gate.
|
||||
'auq-consistency': [ 'ETHOS.md', 'test/skill-e2e-auq-consistency.test.ts', 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts',
|
||||
'test/helpers/auq-sdk-capture.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/llm-judge.ts', 'test/helpers/e2e-gate.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'test/helpers/auq-sdk-capture.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/llm-judge.ts', 'test/helpers/e2e-gate.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts',
|
||||
],
|
||||
'auq-verbose-vs-carved-ab': [ 'ETHOS.md', 'test/skill-e2e-auq-verbose-vs-carved-ab.test.ts', 'test/fixtures/auq-pre-cut-plan-ceo-review-SKILL.md', 'plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts',
|
||||
'test/helpers/auq-sdk-capture.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/llm-judge.ts', 'test/helpers/e2e-gate.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'test/helpers/auq-sdk-capture.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/llm-judge.ts', 'test/helpers/e2e-gate.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts',
|
||||
],
|
||||
'codex-recommendation-substance': [ 'test/codex-e2e-recommendation-substance.test.ts', 'codex/**', 'test/helpers/codex-session-runner.ts', 'test/helpers/llm-judge.ts', 'test/helpers/e2e-gate.ts',
|
||||
@@ -1208,8 +1208,8 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'test/skill-e2e-model-panel.test.ts',
|
||||
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
|
||||
|
||||
'office-hours-section-loading': [ 'ETHOS.md', 'office-hours/**', 'bin/gstack-office-hours-review', 'lib/office-hours-review.ts', 'lib/fs-atomic.ts', 'lib/spec-review-cap.ts', 'test/helpers/office-hours-formatter-fixture.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/carve-guards.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/office-hours-completion.ts', 'test/helpers/llm-judge.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-office-hours-section-loading.test.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'office-hours-design-draft': [ 'ETHOS.md', 'office-hours/**', 'lib/office-hours-review.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/carve-guards.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/office-hours-completion.ts', 'test/helpers/llm-judge.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-office-hours-design-draft.test.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'office-hours-section-loading': [ 'ETHOS.md', 'office-hours/**', 'bin/gstack-office-hours-review', 'lib/office-hours-review.ts', 'lib/fs-atomic.ts', 'lib/spec-review-cap.ts', 'test/helpers/office-hours-formatter-fixture.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/carve-guards.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/office-hours-completion.ts', 'test/helpers/llm-judge.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-office-hours-section-loading.test.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'office-hours-design-draft': [ 'ETHOS.md', 'office-hours/**', 'lib/office-hours-review.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/carve-guards.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/office-hours-completion.ts', 'test/helpers/llm-judge.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-office-hours-design-draft.test.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'dx-error-contract-review': ['test/skill-e2e-dx-error-contract.test.ts', 'test/fixtures/dx-error-contract-case.ts', 'lib/dx-error-contract.ts', 'scripts/resolvers/dx.ts', 'scripts/resolvers/index.ts', 'scripts/gen-skill-docs.ts', 'plan-devex-review/**', 'bin/gstack-autoplan-snapshot.ts', 'lib/autoplan-prompts.ts', 'lib/autoplan-reconcile.ts', 'lib/coordinator-contract.ts', 'test/helpers/session-runner.ts', 'test/helpers/eval-store.ts', 'test/helpers/eval-budgets.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts'],
|
||||
'plan-devex-peer-comparison-classification': [
|
||||
|
||||
@@ -1220,11 +1220,11 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
|
||||
'test/skill-e2e-plan-decision-classification.test.ts', 'test/fixtures/plan-decision-classification.ts', 'test/helpers/plan-review-decisions.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/llm-judge.ts', 'lib/eval-model.ts', 'test/helpers/e2e-helpers.ts', 'test/helpers/eval-store.ts', 'test/helpers/eval-budgets.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'docs/askuserquestion-split.md', 'plan-ceo-review/SKILL.md.tmpl', 'plan-ceo-review/sections/review-sections.md.tmpl', 'scripts/resolvers/tasks-section.ts'],
|
||||
'health-reporting': ['health/**', 'test/skill-e2e-health.test.ts', 'test/helpers/health-eval-fixture.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-claude-dedicated-tools-vs-bash': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-opus-4-7-effort-match-trivial': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-opus-4-7-effort-match-trivial.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-opus-4-7-literal-interpretation': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-opus-4-7-literal-interpretation.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-claude-dedicated-tools-vs-bash-sonnet': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash-sonnet.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-claude-dedicated-tools-vs-bash': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-opus-4-7-effort-match-trivial': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-opus-4-7-effort-match-trivial.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-opus-4-7-literal-interpretation': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-opus-4-7-literal-interpretation.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-claude-dedicated-tools-vs-bash-sonnet': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash-sonnet.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5': ['model-overlays/**', 'test/fixtures/overlay-nudges.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts', 'scripts/resolvers/model-overlay.ts', 'test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5.test.ts', 'test/helpers/overlay-measurement.ts', 'test/helpers/overlay-workspace.ts', 'test/helpers/overlay-attempt.ts', 'test/helpers/overlay-case.ts', 'test/helpers/overlay-case-policy.ts', 'test/helpers/overlay-lifecycle.ts', 'test/fixtures/overlay-admission-child.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'journey-negatives': [ 'ETHOS.md',
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* from the printed SKILL_ROOT; concurrent publishers of one identity agree;
|
||||
* the committed checkout stays byte-identical.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
@@ -19,7 +19,14 @@ import { LOCK_FILE } from '../scripts/modules-render';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-install-'));
|
||||
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const savedState = { GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_STATE_ROOT: process.env.GSTACK_STATE_ROOT };
|
||||
beforeAll(() => {
|
||||
process.env.GSTACK_HOME = process.env.GSTACK_STATE_ROOT = path.join(tmp, 'empty-state');
|
||||
});
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(savedState)) if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
const FULL_ROOT = '/opt/claude/skills';
|
||||
const lite = selectModules({ loadout: 'lite' })!;
|
||||
const run = (...args: string[]) => {
|
||||
@@ -64,7 +71,8 @@ describe('gstack-modules install', () => {
|
||||
expect(landing).toContain(`${FULL_ROOT}/gstack/bin/`);
|
||||
expect(landing).not.toContain('~/.claude/skills/gstack/bin/');
|
||||
expect(lock).toMatchObject({ kind: 'install', loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: FULL_ROOT });
|
||||
expect(lock.excluded).toHaveLength(11);
|
||||
expect(lock.excluded).toHaveLength(10);
|
||||
expect(lock.excluded).not.toContain('preamble:evidence');
|
||||
expect(lock.excluded).not.toContain('preamble:upgrade-check');
|
||||
expect(lock.excluded).not.toContain('preamble:brain-sync');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(root, LOCK_FILE), 'utf-8')).identity.source_tree).toBe(sourceTree(ROOT));
|
||||
@@ -81,6 +89,32 @@ describe('gstack-modules install', () => {
|
||||
expect(explicit).not.toContain('--loadout');
|
||||
}, 240_000);
|
||||
|
||||
test('the lite tree keeps the install settings the full render uses: gbrain detection and disabled_skills, both in the identity (GST-944)', () => {
|
||||
const state = path.join(tmp, 'settings-state');
|
||||
const store = path.join(tmp, 'settings');
|
||||
fs.mkdirSync(state, { recursive: true });
|
||||
const install = () => {
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), 'install', '--model', 'claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000, env: { ...process.env, GSTACK_HOME: state, GSTACK_STATE_ROOT: state } });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stderr).not.toContain('LOADOUT_FALLBACK');
|
||||
return r.stdout.match(/^SKILL_ROOT: (.+)$/m)![1];
|
||||
};
|
||||
const plain = install();
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'disabled_skills: qa\n');
|
||||
const onlyDisabled = install();
|
||||
expect(onlyDisabled).not.toBe(plain);
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'disabled_skills: "/qa, gstack-qa gstack-upgrade"\n');
|
||||
expect(install()).toBe(onlyDisabled);
|
||||
fs.writeFileSync(path.join(state, 'gbrain-detection.json'), JSON.stringify({ gbrain_local_status: 'ok' }));
|
||||
const set = install();
|
||||
expect(set).not.toBe(onlyDisabled);
|
||||
const officeHours = (root: string) => fs.readFileSync(path.join(root, 'office-hours/SKILL.md'), 'utf-8');
|
||||
expect(officeHours(set).split('gbrain').length).toBeGreaterThan(officeHours(plain).split('gbrain').length);
|
||||
const router = fs.readFileSync(path.join(set, 'SKILL.md'), 'utf-8');
|
||||
expect(router).toContain('Disabled on this install (never invoke or suggest them): `/qa`.');
|
||||
expect(fs.readFileSync(path.join(plain, 'SKILL.md'), 'utf-8')).not.toContain('Disabled on this install');
|
||||
}, 240_000);
|
||||
|
||||
test('concurrent publishers of one identity agree on one folder; a tampered folder is refused, not reused', async () => {
|
||||
const store = path.join(tmp, 'race');
|
||||
const [x, y] = await Promise.all([1, 2].map(() => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })));
|
||||
|
||||
@@ -48,8 +48,9 @@ describe('modules/loadouts.json', () => {
|
||||
expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']);
|
||||
});
|
||||
|
||||
test('lite drops the eleven coaching pieces, keeps upgrade-check and brain-sync, and only Opus 5.5 maps to it', () => {
|
||||
const coaching = ['voice', 'writing-style', 'completeness', 'confusion', 'evidence', 'context-health', 'question-tuning', 'search-before-building', 'repo-mode', 'brain-health', 'context-recovery'];
|
||||
test('lite drops ten coaching pieces, keeps upgrade-check, brain-sync and the pinned Claims Need Evidence rule, and only Opus 5.5 maps to it', () => {
|
||||
expect(manifest.pinned).toContain('preamble:evidence');
|
||||
const coaching = ['voice', 'writing-style', 'completeness', 'confusion', 'context-health', 'question-tuning', 'search-before-building', 'repo-mode', 'brain-health', 'context-recovery'];
|
||||
expect(manifest.loadouts.lite).toMatchObject({ base: 'full', exclude: coaching.map(id => `preamble:${id}`) });
|
||||
expect(manifest.models).toEqual({ 'claude-opus-5-5': 'lite' });
|
||||
const s = selectModules({ loadout: 'lite' })!;
|
||||
|
||||
@@ -15,7 +15,7 @@ const sample = (metric = 3): OverlayTrialOutcome => ({ passed: true, taskCorrect
|
||||
const sdkResult = (): AgentSdkResult => ({ events: [{ type: 'result', subtype: 'success' }] as AgentSdkResult['events'], assistantTurns: [], toolCalls: [], output: 'done', exitReason: 'success', turnsUsed: 1, durationMs: 1, firstResponseMs: 1, maxInterTurnMs: 0, costUsd: .01, model: 'test', sdkVersion: 'test', sdkClaudeCodeVersion: 'test', resolvedBinaryPath: 'test', browseErrors: [] });
|
||||
|
||||
function roots() { return fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-lifecycle-free-')); }
|
||||
function opts(dir: string, provider: QueryProvider, signal: AbortSignal) { return { systemPrompt: '', userPrompt: 'test', workingDirectory: dir, queryProvider: provider, signal }; }
|
||||
function opts(dir: string, provider: QueryProvider, signal: AbortSignal) { return { systemPrompt: { type: 'sdk-default', reason: 'stub queryProvider: no model reads the prompt' } as const, userPrompt: 'test', workingDirectory: dir, queryProvider: provider, signal }; }
|
||||
|
||||
test('every fixture owns one paid wrapper; public models/trials/concurrency/turn caps and walls are preserved', () => {
|
||||
expect(Object.values(OVERLAY_CASE_FILES).sort()).toEqual(OVERLAY_FIXTURES.map(f => f.id).sort());
|
||||
|
||||
@@ -9,7 +9,7 @@ import { captureOverlayQueryAttempts } from './helpers/overlay-attempt';
|
||||
|
||||
const terminal = { type: 'result', subtype: 'success', is_error: false, num_turns: 1, total_cost_usd: 0 };
|
||||
const assistant = { type: 'assistant', message: { id: 'owned', content: [{ type: 'text', text: 'owned fake result' }] } };
|
||||
const options = (directory: string, signal: AbortSignal, queryProvider: QueryProvider) => ({ workingDirectory: directory, signal, queryProvider, systemPrompt: '', userPrompt: 'owned free probe', settingSources: [], env: { ANTHROPIC_API_KEY: '', HOME: directory, GSTACK_HOME: path.join(directory, '.gstack') } });
|
||||
const options = (directory: string, signal: AbortSignal, queryProvider: QueryProvider) => ({ workingDirectory: directory, signal, queryProvider, systemPrompt: { type: 'sdk-default', reason: 'fake Claude Code child script: no model reads the prompt' } as const, userPrompt: 'owned free probe', settingSources: [], env: { ANTHROPIC_API_KEY: '', HOME: directory, GSTACK_HOME: path.join(directory, '.gstack') } });
|
||||
|
||||
test('SDK cancellation after the terminal event but before EOF rejects and releases its permit', async () => {
|
||||
__resetSemaphoreForTests(1);
|
||||
|
||||
@@ -123,6 +123,29 @@ describe('a case shard\'s result records are its one case\'s cost (2026-10-26 tr
|
||||
expect(collector(r.dir).verdict.costUsd).toBe(0.75);
|
||||
});
|
||||
|
||||
test('a record-less trial\'s streamed session cost reaches the trial and the verdict total once (GSTA-150 outside voice: $0.65 known, headline $0.00)', () => {
|
||||
const ledger = (dir: string, slug: string, costs: number[]) => fs.writeFileSync(path.join(shard(dir, slug), 'session-ledger.jsonl'),
|
||||
costs.map(cost => JSON.stringify({ key: 'k', runner: 'agent-sdk', started_at: 't', elapsed_ms: 1, end: 'aborted', billed: false, streamed_cost_usd: cost })).join('\n') + '\n');
|
||||
const r = report('periodic', dir => {
|
||||
ledger(dir, 'skill-e2e-qa-workflow--qa-fix-loop', [0.4, 0.25]);
|
||||
results(dir, 'skill-e2e-benchmark-providers', [{ name: 'benchmark-providers-live', suite: 's', tier: 'e2e', passed: true, duration_ms: 1, cost_usd: 0.1 }]);
|
||||
ledger(dir, 'skill-e2e-benchmark-providers', [0.5]);
|
||||
});
|
||||
expect(r.history.find(record => record.case === 'qa-fix-loop')).toMatchObject({ outcome: 'passed', cost_usd: 0.65 });
|
||||
expect(r.history.find(record => record.case === 'benchmark-providers-live')).toMatchObject({ cost_usd: 0.6 });
|
||||
expect(collector(r.dir).verdict.costUsd).toBe(1.25);
|
||||
expect(r.out).toContain('cost $1.25 known + 5 of 7 trial(s) cost unknown');
|
||||
});
|
||||
|
||||
test('a billed session\'s cost is already in its collector total, so the verdict never adds it again (Codex host, unbound label record)', () => {
|
||||
const r = report('periodic', dir => {
|
||||
results(dir, 'skill-e2e-cso', [{ name: '/cso full audit', suite: 'cso', tier: 'e2e', passed: true, duration_ms: 1, cost_usd: 0.5 }]);
|
||||
fs.writeFileSync(path.join(shard(dir, 'skill-e2e-cso'), 'session-ledger.jsonl'),
|
||||
JSON.stringify({ key: 'k', runner: 'codex', started_at: 't', elapsed_ms: 1, end: 'completed', billed: true, cost_usd: 0.5 }) + '\n');
|
||||
});
|
||||
expect(collector(r.dir).verdict.costUsd).toBe(0.5);
|
||||
});
|
||||
|
||||
// Value: protects=a failed case shard's trial reads its failure class, exit reason, error and cost from the display-labelled record the shard
|
||||
// wrote, not a generic 'assertion' with the JUnit message (the same attribution the cost fix made; train-gate's INFRA/VERDICT class follows failure_class);
|
||||
// fails_when=failedRecord is looked up through the old name/case_id-only filter again so the record's class and error are dropped;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* PR 2 of GSTA-150: every trial's cost is attributed. A display-label record binds its cost by
|
||||
* runSkillTest's testName (cost_case_id) without changing the failed record or failure class; a case
|
||||
* with no record takes its sessions' streamed cost; a listed model-free case costs a known $0.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { MODEL_FREE_CASES, caseCost, junitCensus } from '../scripts/lib/paid-report';
|
||||
import { anthropicUsageCostUsd, streamedUsageCostUsd } from '../lib/pricing';
|
||||
import { E2E_TOUCHFILES } from './helpers/touchfiles-data';
|
||||
import type { SessionLedgerRow } from './helpers/session-ledger';
|
||||
import { recordE2E } from './helpers/e2e-helpers';
|
||||
import { newTranscriptSessionId, pricedTranscript, ptyTranscriptCostUsd, transcriptFiles } from './helpers/pty/transcript-cost';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const session = (over: Partial<SessionLedgerRow>): SessionLedgerRow => ({ key: 'k', runner: 'agent-sdk', started_at: 't', elapsed_ms: 1, end: 'aborted', ...over });
|
||||
|
||||
describe('pricing usage', () => {
|
||||
test('cache writes bill 1.25x input (5-minute) and 2x (1-hour); reads at the cached rate', () => {
|
||||
// claude-opus-5-5: input $4, output $20, cache read $0.20 per Mtok.
|
||||
expect(anthropicUsageCostUsd({ input_tokens: 1_000_000, output_tokens: 1_000_000, cache_read_input_tokens: 1_000_000, cache_creation_input_tokens: 1_000_000 }, 'claude-opus-5-5'))
|
||||
.toBeCloseTo(4 + 20 + 0.2 + 5, 6);
|
||||
expect(anthropicUsageCostUsd({ cache_creation_input_tokens: 3_000_000, cache_creation: { ephemeral_5m_input_tokens: 1_000_000, ephemeral_1h_input_tokens: 2_000_000 } }, 'claude-opus-5-5'))
|
||||
.toBeCloseTo(5 + 16, 6);
|
||||
expect(anthropicUsageCostUsd({ cache_creation_input_tokens: 3_000_000, cache_creation: { ephemeral_1h_input_tokens: 2_000_000 } }, 'claude-opus-5-5'))
|
||||
.toBeCloseTo(5 + 16, 6);
|
||||
expect(anthropicUsageCostUsd({ input_tokens: 10 }, 'no-such-model')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('streamed messages count once per id at their largest output, sidechains included; an unpriced model is unknown', () => {
|
||||
const msg = (id: string, output: number, extra: Record<string, unknown> = {}) => ({ message: { id, model: 'claude-opus-5-5', usage: { input_tokens: 0, output_tokens: output } }, ...extra });
|
||||
expect(streamedUsageCostUsd([msg('a', 10), msg('a', 1_000_000), msg('b', 500_000, { parent_tool_use_id: 'tool-1' })])).toBeCloseTo(30, 6);
|
||||
expect(streamedUsageCostUsd([{ message: { id: 'a', model: '<synthetic>', usage: { output_tokens: 5 } } }])).toBeUndefined();
|
||||
expect(streamedUsageCostUsd([{ message: { id: 'a', model: 'no-such-model', usage: { output_tokens: 5 } } }, msg('b', 5)])).toBeUndefined();
|
||||
expect(streamedUsageCostUsd([{ message: { id: 'a', usage: { output_tokens: 1_000_000 } } }], 'claude-opus-5-5')).toBeCloseTo(20, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('caseCost', () => {
|
||||
test('a billed session\'s cost is already in the records; a streamed session adds what no record carries', () => {
|
||||
expect(caseCost('x', [{ cost_usd: 0.25 }, { cost_usd: 0.1 }], [session({ billed: true, cost_usd: 9 })])).toEqual({ cost_usd: 0.35 });
|
||||
expect(caseCost('x', [{ cost_usd: 0 }], [session({ billed: false, streamed_cost_usd: 0.4 })])).toEqual({ cost_usd: 0.4 });
|
||||
expect(caseCost('x', [{ cost_usd: 0.2 }], [session({ runner: 'pty', billed: false })])).toEqual({ cost_usd: 0.2, cost_known: false });
|
||||
expect(caseCost('x', [{ cost_usd: 0.2, cost_known: false }], [])).toEqual({ cost_usd: 0.2, cost_known: false });
|
||||
});
|
||||
|
||||
test('a case with no record takes its sessions\' billed or streamed cost, else stays unknown', () => {
|
||||
expect(caseCost('auq', [], [session({ billed: false, streamed_cost_usd: 0.123 })])).toEqual({ cost_usd: 0.12 });
|
||||
expect(caseCost('auq', [], [session({ billed: false })])).toEqual({ cost_usd: 0, cost_known: false });
|
||||
expect(caseCost('auq', [], [session({ runner: 'claude-p', billed: true })])).toEqual({ cost_usd: 0, cost_known: false });
|
||||
});
|
||||
|
||||
test('only a listed model-free case with neither record nor session costs a known $0', () => {
|
||||
expect(caseCost('timeline-event-flow', [], [])).toEqual({ cost_usd: 0 });
|
||||
expect(caseCost('some-other-case', [], [])).toEqual({ cost_usd: 0, cost_known: false });
|
||||
});
|
||||
|
||||
test('every listed model-free case body starts no model session', () => {
|
||||
for (const id of MODEL_FREE_CASES) {
|
||||
const file = E2E_TOUCHFILES[id]?.find(entry => /^test\/[^/]+\.test\.ts$/.test(entry));
|
||||
expect(file, id).toBeTruthy();
|
||||
const source = fs.readFileSync(path.join(ROOT, file!), 'utf8');
|
||||
const start = source.search(new RegExp(`IfSelected\\(\\s*['"\`]${id}['"\`]`));
|
||||
expect(start, id).toBeGreaterThan(-1);
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.search(/IfSelected\(/);
|
||||
const body = end === -1 ? rest : rest.slice(0, end);
|
||||
expect(body, id).not.toMatch(/runSkillTest|runAgentSdkTest|runCodex|launchClaudePty|runPtySession|claude-pty|callJudge|judge\w*\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordE2E cost_case_id', () => {
|
||||
const entry = (name: string, testName?: string) => {
|
||||
const tests: any[] = [];
|
||||
const result: any = { toolCalls: [], browseErrors: [], exitReason: 'success', duration: 1, output: '', transcript: [], model: 'claude-opus-5-5',
|
||||
costEstimate: { estimatedCost: 0.05, turnsUsed: 1, estimatedTokens: 1 }, ...(testName ? { testName } : {}) };
|
||||
recordE2E({ addTest: (t: any) => tests.push(t) } as any, name, 'suite', result);
|
||||
return tests[0];
|
||||
};
|
||||
|
||||
test('a display-label record carries its registered runSkillTest testName as cost_case_id, and nothing else does', () => {
|
||||
expect(entry('browse basic commands', 'browse-basic')).toMatchObject({ name: 'browse basic commands', cost_case_id: 'browse-basic' });
|
||||
expect(entry('browse-basic', 'browse-basic').cost_case_id).toBeUndefined();
|
||||
expect(entry('browse basic commands', 'not-a-registered-case').cost_case_id).toBeUndefined();
|
||||
expect(entry('browse basic commands').cost_case_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('junitCensus cost binding', () => {
|
||||
const key = 'test/skill-e2e-bws.test.ts';
|
||||
const census = (records: any[], ledger: SessionLedgerRow[] = []) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'paid-report-cost-'));
|
||||
const shard = path.join(root, 'shards', 'skill-e2e-bws');
|
||||
fs.mkdirSync(shard, { recursive: true });
|
||||
fs.writeFileSync(path.join(shard, 'junit.xml'), `<testsuites><testsuite name="${key}">
|
||||
<testcase name="browse-basic" classname="${key}" time="1"></testcase>
|
||||
<testcase name="browse-snapshot" classname="${key}" time="1"><failure type="AssertionError" message="expected snapshot"/></testcase>
|
||||
</testsuite></testsuites>`);
|
||||
fs.writeFileSync(path.join(shard, 'session-ledger.jsonl'), ledger.map(row => JSON.stringify(row)).join('\n'));
|
||||
const manifest: any = { version: 1, tier: 'gate', evalsAll: true, sliceCount: 1, profile: 'full', selection: { e2e: null, judges: null }, entries: [{ file: key, slice: 1, status: 'planned', estimatedMs: 1 }] };
|
||||
const result: any = { version: 1, tier: 'gate', profile: 'full', selection: { e2e: null, judges: null }, sliceIndex: 1, sliceCount: 1, attempt: 1,
|
||||
outcomes: [{ files: [key], status: 'failed', exitCode: 1, elapsedMs: 10, executedTests: 2, skippedTests: 0 }] };
|
||||
try { return junitCensus(manifest, [{ root, result }], new Map([[key, records]]), {}); } finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
};
|
||||
const labelled = [
|
||||
{ name: 'browse basic commands', passed: true, cost_usd: 0.05, cost_case_id: 'browse-basic' },
|
||||
{ name: 'browse snapshot flags', passed: false, cost_usd: 0.06, cost_case_id: 'browse-snapshot', exit_reason: 'timeout', failure_class: 'infra', error: 'record error' },
|
||||
];
|
||||
|
||||
test('display-label records bind their cost by cost_case_id in a multi-case shard', () => {
|
||||
const cases = census(labelled).ruleCases;
|
||||
expect(cases.map(c => [c.id, c.costUsd, c.costKnown])).toEqual([['browse-basic', 0.05, true], ['browse-snapshot', 0.06, true]]);
|
||||
});
|
||||
|
||||
test('the failed record and failure class stay what the named records give, as without cost_case_id', () => {
|
||||
const bound = census(labelled).ruleCases.find(c => c.id === 'browse-snapshot')!;
|
||||
const unbound = census(labelled.map(({ cost_case_id, ...rest }) => rest)).ruleCases.find(c => c.id === 'browse-snapshot')!;
|
||||
expect(unbound).toMatchObject({ costUsd: 0, costKnown: false });
|
||||
expect({ failureClass: bound.failureClass, error: bound.error, line: bound.line?.replace(/ {2}\[slice.*$/, '') })
|
||||
.toEqual({ failureClass: unbound.failureClass, error: unbound.error, line: unbound.line?.replace(/ {2}\[slice.*$/, '') });
|
||||
expect(bound.failureClass).toBe('assertion');
|
||||
});
|
||||
|
||||
test('a cost-only record never sets the trial\'s model, which keys its pass-rate series', () => {
|
||||
const history = census(labelled.map(r => ({ ...r, model: 'claude-opus-5-5' }))).history;
|
||||
expect(history.map(h => [h.case, h.model])).toEqual([['browse-basic', undefined], ['browse-snapshot', undefined]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PTY transcript pricing (GSTA-150 PR 3)', () => {
|
||||
const SID = '11111111-2222-4333-8444-555555555555';
|
||||
const OTHER = '99999999-2222-4333-8444-555555555555';
|
||||
const assistant = (sid: string, id: string, model: string, usage: object, extra: object = {}) =>
|
||||
JSON.stringify({ type: 'assistant', sessionId: sid, cwd: '/fixture/cwd', message: { id, model, role: 'assistant', usage }, ...extra });
|
||||
const fixture = (write: (project: string) => void) => {
|
||||
const config = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-cost-'));
|
||||
const project = path.join(config, 'projects', '-fixture-cwd');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
write(project);
|
||||
return config;
|
||||
};
|
||||
|
||||
test('prices the session, its inline sidechains and subagent files, each message id once at its largest output', () => {
|
||||
const config = fixture(project => {
|
||||
fs.writeFileSync(path.join(project, `${SID}.jsonl`), [
|
||||
JSON.stringify({ type: 'user', sessionId: SID, message: { role: 'user', content: 'hi' } }),
|
||||
assistant(SID, 'msg_a', 'claude-opus-5-5', { input_tokens: 1000, output_tokens: 10 }),
|
||||
assistant(SID, 'msg_a', 'claude-opus-5-5', { input_tokens: 1000, output_tokens: 500 }),
|
||||
assistant(SID, 'msg_side', 'claude-opus-5-5', { input_tokens: 0, output_tokens: 1000 }, { isSidechain: true }),
|
||||
assistant(SID, 'msg_syn', '<synthetic>', { input_tokens: 0, output_tokens: 0 }),
|
||||
'{"partial":',
|
||||
].join('\n'));
|
||||
fs.mkdirSync(path.join(project, SID, 'subagents'), { recursive: true });
|
||||
fs.writeFileSync(path.join(project, SID, 'subagents', 'agent-x.jsonl'),
|
||||
assistant(SID, 'msg_sub', 'claude-opus-5-5', { input_tokens: 0, cache_read_input_tokens: 10000, output_tokens: 0 }) + '\n');
|
||||
fs.writeFileSync(path.join(project, `${OTHER}.jsonl`), assistant(OTHER, 'msg_foreign', 'claude-opus-5-5', { input_tokens: 1_000_000 }) + '\n');
|
||||
});
|
||||
try {
|
||||
// opus-5-5: $4 in, $20 out, $0.20 cached per MTok.
|
||||
const expected = (1000 * 4 + 500 * 20 + 1000 * 20 + 10000 * 0.2) / 1_000_000;
|
||||
expect(ptyTranscriptCostUsd(config, SID)).toBeCloseTo(expected, 6);
|
||||
expect(pricedTranscript({ transcriptCostUsd: () => ptyTranscriptCostUsd(config, SID) }).streamed_cost_usd).toBeCloseTo(expected, 6);
|
||||
} finally { fs.rmSync(config, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('stays cost-unknown without a transcript, a session id, usage, a priced model or a readable file, and never throws', () => {
|
||||
const unpriced = fixture(project => fs.writeFileSync(path.join(project, `${SID}.jsonl`),
|
||||
assistant(SID, 'msg_a', 'claude-opus-5-5', { output_tokens: 1 }) + '\n' + assistant(SID, 'msg_b', 'some-unpriced-model', { output_tokens: 1 }) + '\n'));
|
||||
const empty = fixture(project => fs.writeFileSync(path.join(project, `${SID}.jsonl`), JSON.stringify({ type: 'user', sessionId: SID }) + '\n'));
|
||||
const unreadable = fixture(project => fs.mkdirSync(path.join(project, `${SID}.jsonl`)));
|
||||
try {
|
||||
expect(ptyTranscriptCostUsd(unpriced, SID)).toBeUndefined();
|
||||
expect(ptyTranscriptCostUsd(empty, SID)).toBeUndefined();
|
||||
expect(ptyTranscriptCostUsd(empty, OTHER)).toBeUndefined();
|
||||
expect(ptyTranscriptCostUsd(unpriced, undefined)).toBeUndefined();
|
||||
expect(ptyTranscriptCostUsd(null, SID)).toBeUndefined();
|
||||
expect(ptyTranscriptCostUsd(path.join(empty, 'missing'), SID)).toBeUndefined();
|
||||
expect(ptyTranscriptCostUsd(unreadable, SID)).toBeUndefined();
|
||||
expect(newTranscriptSessionId(unreadable, new Set(), '/fixture/cwd')).toBeUndefined();
|
||||
expect(pricedTranscript(undefined)).toEqual({});
|
||||
expect(pricedTranscript({})).toEqual({});
|
||||
} finally { for (const dir of [unpriced, empty, unreadable]) fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a launch claims only the one transcript it created for its own cwd', () => {
|
||||
const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pty-cost-cwd-')));
|
||||
const line = (sid: string, at: string) => JSON.stringify({ type: 'user', sessionId: sid, cwd: at }) + '\n';
|
||||
const config = fixture(project => fs.writeFileSync(path.join(project, `${OTHER}.jsonl`), line(OTHER, cwd)));
|
||||
try {
|
||||
const before = transcriptFiles(config);
|
||||
const project = path.join(config, 'projects', '-fixture-cwd');
|
||||
fs.writeFileSync(path.join(project, '22222222-2222-4333-8444-555555555555.jsonl'), line('22222222-2222-4333-8444-555555555555', '/a/concurrent/test'));
|
||||
expect(newTranscriptSessionId(config, before, cwd)).toBeUndefined();
|
||||
fs.writeFileSync(path.join(project, `${SID}.jsonl`), line(SID, cwd));
|
||||
expect(newTranscriptSessionId(config, before, cwd)).toBe(SID);
|
||||
fs.writeFileSync(path.join(project, '33333333-2222-4333-8444-555555555555.jsonl'), line('33333333-2222-4333-8444-555555555555', cwd));
|
||||
expect(newTranscriptSessionId(config, before, cwd)).toBeUndefined();
|
||||
} finally { fs.rmSync(config, { recursive: true, force: true }); fs.rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,11 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { planRuns } from '../lib/pregate/checks/lanes';
|
||||
import { DEFAULT_CONFIG } from '../lib/pregate/config';
|
||||
import { classifyCommandEnvironment, classifyEnvironment, commandBinary } from '../lib/pregate/environment';
|
||||
import { verifyPregate } from '../lib/pregate/index';
|
||||
import type { PregateContext } from '../lib/pregate/types';
|
||||
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
@@ -171,6 +174,83 @@ describe('environment failures are reported apart from diff failures', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a detected pytest runner needs pytest importable by python3', () => {
|
||||
const PY_TEST = { 'tests/test_alpha.py': 'import unittest\n\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1 + 1, 2)\n' };
|
||||
/** A directory holding only a `python3` whose `-c` probe exits `probeExit` (0: pytest importable, 3: not, anything else: the probe itself failed). */
|
||||
const python3Shim = (repo: FixtureRepo, probeExit: number): string => {
|
||||
const dir = path.join(repo.root, `py-${probeExit}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'python3'), `#!/bin/sh\n[ "$1" = "-c" ] && exit ${probeExit}\necho "unexpected: python3 $*"\nexit 3\n`, { mode: 0o755 });
|
||||
return dir;
|
||||
};
|
||||
const ctxFor = (repo: FixtureRepo, PATH: string) => ({ repoRoot: repo.work, config: DEFAULT_CONFIG, env: { PATH } }) as unknown as PregateContext;
|
||||
|
||||
// Value: protects=a `.py` selection plans `python3 -m pytest` only when python3 can import pytest; otherwise the tool to install is
|
||||
// named (`pytest`, or `python3` when it is off PATH) and the file is unclaimed; fails_when=the pytest branch plans the runner
|
||||
// unchecked again (#3598); why_new=pnpm/cargo/dart had a pre-run check, pytest none; seam=none; no_claim=a real pytest install
|
||||
test('plans pytest when python3 imports it, else names pytest or python3 as missing', () => {
|
||||
const repo = makeFixtureRepo({ pregate: null, baseFiles: PY_TEST }); repos.push(repo);
|
||||
const files = ['tests/test_alpha.py'];
|
||||
expect(planRuns(ctxFor(repo, python3Shim(repo, 0)), files)).toEqual({ groups: [{ runner: 'python3 -m pytest {files}', cwd: '', files }], unclaimed: [], missing: [], noPubspec: [] });
|
||||
expect(planRuns(ctxFor(repo, python3Shim(repo, 3)), files)).toEqual({ groups: [], unclaimed: files, missing: ['pytest'], noPubspec: [] });
|
||||
// A probe that crashes (a repo-root module shadowing importlib exits 1) is not read as a missing pytest: the run decides.
|
||||
expect(planRuns(ctxFor(repo, python3Shim(repo, 1)), files).groups).toEqual([{ runner: 'python3 -m pytest {files}', cwd: '', files }]);
|
||||
expect(planRuns(ctxFor(repo, path.join(repo.root, 'empty-path')), files)).toEqual({ groups: [], unclaimed: files, missing: ['python3'], noPubspec: [] });
|
||||
});
|
||||
|
||||
// Value: protects=the reported case end to end: a machine without pytest gets PREGATE_ENVIRONMENT missing-binary naming pytest and
|
||||
// "the diff is not at fault", and the lane never runs; fails_when=it is PREGATE_LANE_FAILED with "fix the in-branch failure"
|
||||
// (#3598); why_new=no pytest environment case existed; seam=none; no_claim=a real pytest install
|
||||
test('a machine without pytest is PREGATE_ENVIRONMENT naming pytest, never PREGATE_LANE_FAILED', () => {
|
||||
const repo = makeFixtureRepo({ pregate: null, baseFiles: PY_TEST }); repos.push(repo);
|
||||
commit(repo, { 'tests/test_alpha.py': `${PY_TEST['tests/test_alpha.py']}# touched\n` });
|
||||
const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], { PATH: `${python3Shim(repo, 3)}:${process.env.PATH ?? ''}` });
|
||||
expect(r.err).toContain('(PREGATE_ENVIRONMENT)');
|
||||
expect(r.err).not.toContain('PREGATE_LANE_FAILED');
|
||||
expect(r.out).not.toContain('unexpected: python3');
|
||||
const c = check(repo, 'lanes');
|
||||
expect([c.status, c.code, c.cause, c.environment.kind]).toEqual(['incomplete', 'PREGATE_ENVIRONMENT', 'environment', 'missing-binary']);
|
||||
expect(c.environment.evidence).toContain('pytest');
|
||||
expect(c.fix).toContain('install pytest');
|
||||
expect(c.fix).toContain('the diff is not at fault');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Playwright without its browsers is the machine, not the diff', () => {
|
||||
const SPEC = { 'package.json': JSON.stringify({ name: 'fixture', devDependencies: { '@playwright/test': '^1.48.0' } }) + '\n', 'tests/e2e/home.spec.ts': "import { test } from '@playwright/test';\ntest('home', async ({ page }) => { await page.goto('/'); });\ntest('about', async ({ page }) => { await page.goto('/about'); });\n" };
|
||||
const NO_BROWSERS = ['Running 2 tests using 1 worker', ' x 1 tests/e2e/home.spec.ts:2:5 > home (12ms)', ' x 2 tests/e2e/home.spec.ts:3:5 > about (8ms)', '', ' 1) tests/e2e/home.spec.ts:2:5 > home', " Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1169/chrome-linux/headless_shell", ' Looks like Playwright Test or Playwright was just installed or updated.', ' Please run the following command to download new browsers:', ' npx playwright install', ' 2 failed', ' tests/e2e/home.spec.ts:2:5 > home', ' tests/e2e/home.spec.ts:3:5 > about'];
|
||||
const ASSERTION = ['Running 2 tests using 1 worker', ' 1) tests/e2e/home.spec.ts:2:5 > home', ' Error: expect(received).toBe(expected)', ' 1 failed', ' tests/e2e/home.spec.ts:2:5 > home', ' 1 passed (2.0s)'];
|
||||
/** An `npx` first on PATH (and again through BASH_ENV, since the lane puts its Bun's directory first) that prints `lines` and exits 1. */
|
||||
const npxShim = (repo: FixtureRepo, lines: string[]) => {
|
||||
const dir = path.join(repo.root, 'npx-shim');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'out.txt'), lines.join('\n') + '\n');
|
||||
fs.writeFileSync(path.join(dir, 'npx'), `#!/bin/sh\ncat '${path.join(dir, 'out.txt')}'\nexit 1\n`, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(dir, 'path.sh'), `PATH='${dir}':"$PATH"\n`);
|
||||
return { PATH: `${dir}:${process.env.PATH ?? ''}`, BASH_ENV: path.join(dir, 'path.sh') };
|
||||
};
|
||||
|
||||
// Value: protects=a Playwright run whose every failure is a missing browser is PREGATE_ENVIRONMENT missing-binary naming
|
||||
// `npx playwright install`, while an ordinary assertion failure stays the diff's; fails_when=the browsers case is PREGATE_LANE_FAILED
|
||||
// "fix the in-branch failure" again (the #3598 misreading for Playwright); why_new=Playwright had no runner before #3629;
|
||||
// seam=none; no_claim=a real Playwright install
|
||||
test('every test failing to launch its browser is PREGATE_ENVIRONMENT; an assertion failure is PREGATE_LANE_FAILED', () => {
|
||||
const repo = makeFixtureRepo({ pregate: null, baseFiles: SPEC }); repos.push(repo);
|
||||
commit(repo, { 'tests/e2e/home.spec.ts': `${SPEC['tests/e2e/home.spec.ts']}// touched\n` });
|
||||
const args = ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'];
|
||||
const r = runBin(repo, 'gstack-pregate', args, npxShim(repo, NO_BROWSERS));
|
||||
expect(r.err).toContain('(PREGATE_ENVIRONMENT)');
|
||||
const c = check(repo, 'lanes');
|
||||
expect([c.status, c.code, c.cause, c.environment.kind]).toEqual(['incomplete', 'PREGATE_ENVIRONMENT', 'environment', 'missing-binary']);
|
||||
expect(c.environment.evidence).toContain('Playwright browsers not installed (npx playwright install)');
|
||||
expect(c.lines.join('\n')).toContain("playwright test --reporter=list '(?:^|/)tests/e2e/home\\.spec\\.ts$'");
|
||||
runBin(repo, 'gstack-pregate', args, npxShim(repo, ASSERTION));
|
||||
const d = check(repo, 'lanes');
|
||||
expect([d.status, d.code]).toEqual(['fail', 'PREGATE_LANE_FAILED']);
|
||||
expect(d.detail).toContain('1 failed of 2 ran');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify: a mixed incomplete record stays PREGATE_INCOMPLETE and names the environment-blocked check', () => {
|
||||
test('one environment-caused check beside a plain incomplete one is not promoted to PREGATE_ENVIRONMENT', () => {
|
||||
const file: any = { schema_version: 1, tree: 'T', checks: [
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('gstack-pregate init drafts a policy from the repo (#3268)', () => {
|
||||
expect(r.out).toContain('PREGATE_CONFIG_WRITTEN: .gstack/pregate.json');
|
||||
expect(r.out).toMatch(/PREGATE_INIT: languages python \d+, javascript \d+/);
|
||||
expect(r.out).toContain('python tests detected at run time: python3 -m pytest {files}');
|
||||
expect(r.out).toContain('a selection mixing these languages needs runner set');
|
||||
expect(r.out).not.toContain('needs runner set');
|
||||
expect(r.out).not.toContain('TODO src/version.js');
|
||||
expect(r.out).toContain('TODO tools/release.py: no test lane and no nearby test');
|
||||
expect(r.out).not.toContain('TODO orders/services.py');
|
||||
|
||||
@@ -258,6 +258,46 @@ describe('literals (pure)', () => {
|
||||
expect(consumersOf(root, all, { name: 'TIMEOUT_MS', old: '5000', new: '9000', file: 'lib/retry.js' })).toEqual(['test/retry.test.ts:1', 'test/retry-path.test.ts:2', 'test/retry-bare.test.ts:2']);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
// Value: protects=a one-character old value counts only in a statement that names the constant (its line, or lines that continue it),
|
||||
// so comments, shell positionals and fixture bodies elsewhere in a file that names it are not consumers, while a formatter-split
|
||||
// assertion still is; fails_when=any lone digit in such a file is reported again
|
||||
// (#3492: five false hits on a wrapper 2 -> 3 bump, the same five on 3 -> 4 at v1.91.376.0); why_new=only file-level gating was
|
||||
// tested; seam=none; no_claim=longer values (module-gated as before)
|
||||
test('consumersOf: a one-character old value counts only in a statement that names the constant (#3492)', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-bound-'));
|
||||
const nl = String.fromCharCode(10);
|
||||
const files: Record<string, string> = {
|
||||
'scripts/install.sh': ['#!/usr/bin/env bash', '# handing the held lock over (GSTACK_DEMO_LOCK_HELD=1) and,', "# from revision 2, origin's VERSION read in this run", 'GSTACK_DEMO_WRAPPER=3', ''].join(nl),
|
||||
'test/prose.test.ts': ["const rev = read(BOOT).match(/^GSTACK_DEMO_WRAPPER=(\\d+)$/m);", '', '// Field list 2 item 13: lanes relay only the trailer', ''].join(nl),
|
||||
'test/stub.test.ts': ["const env = { GSTACK_DEMO_WRAPPER: '9' };", 'const script = [', " 'bash \"$1\" touch --root \"$2\" --pid $$ || exit 9',", '];', ''].join(nl),
|
||||
'test/fixture.test.ts': ['// covers the GSTACK_DEMO_WRAPPER readers', "write('src/a.js', 'export default 1;');", "write('src/b.js', 'export default 2;');", ''].join(nl),
|
||||
'test/same-line.test.ts': ['expect(GSTACK_DEMO_WRAPPER).toBe(2);', ''].join(nl),
|
||||
'test/later.test.ts': ['// readers of the wrapper revision', "it('x', () => { const n = 2; });", 'expect(env.GSTACK_DEMO_WRAPPER).toBe(2);', ''].join(nl),
|
||||
'test/split.test.ts': ['expect(read().GSTACK_DEMO_WRAPPER)', ' .toBe(2);', ''].join(nl),
|
||||
'test/split-args.test.ts': ['assert.strictEqual(', ' mod.GSTACK_DEMO_WRAPPER,', ' 2,', ');', ''].join(nl),
|
||||
'test/adjacent.test.ts': ['const rev = mod.GSTACK_DEMO_WRAPPER;', 'const two = 2;', ''].join(nl),
|
||||
};
|
||||
for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }
|
||||
expect(consumersOf(root, Object.keys(files), { name: 'GSTACK_DEMO_WRAPPER', old: '2', new: '3', file: 'scripts/install.sh' })).toEqual(['test/same-line.test.ts:1', 'test/later.test.ts:3', 'test/split.test.ts:2', 'test/split-args.test.ts:3']);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
// Value: protects=a one-character call-site literal (`attempts: 1`) counts only in a file that also names the changed module, on a line
|
||||
// naming the call-site name, because `attempts`, `limit` and `cap` are ordinary words across a test tree; fails_when=any file with
|
||||
// the word and a lone `1` is reported again (gstack-evals#97: `retry: { attempts: 1 }` -> `JUDGE_RETRY` raised 11 false hits,
|
||||
// GSTA-2); why_new=call-site names had only the constant-name gate; seam=none; no_claim=constant names (previous test)
|
||||
test('consumersOf: a one-character call-site literal needs the changed module in the file and the name on its line (gstack-evals#97)', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-call-'));
|
||||
const nl = String.fromCharCode(10);
|
||||
const files: Record<string, string> = {
|
||||
'tests/grid.test.ts': ['const grid = { tasks: ["a"], attempts: 1, n_concurrent: 4 };', ''].join(nl),
|
||||
'tests/replay.test.ts': ['// attempts are counted per finding', 'expect(r.findings.length).toBe(1);', ''].join(nl),
|
||||
'tests/proxy.test.ts': ["import { retryBudget } from '../lanes/proxy';", 'const cell = { trial: 1, attempt: 1 };', 'expect(retryBudget().attempts).toBeGreaterThan(0);', ''].join(nl),
|
||||
'tests/judge-run.test.ts': ["import { run } from '../lanes/review-noloss/judge-run';", 'const n = 1;', 'expect(run({ retry: { attempts: 1 } }).sent).toBe(n);', ''].join(nl),
|
||||
};
|
||||
for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }
|
||||
expect(consumersOf(root, Object.keys(files), { name: 'attempts', old: '1', new: '3', file: 'lanes/review-noloss/judge-run.ts' })).toEqual(['tests/judge-run.test.ts:3']);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
test('consumersOf: a history row is skipped only when this branch added the new-value row to the same table; other old-value lines still count (GST-344)', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-history-'));
|
||||
const nl = String.fromCharCode(10);
|
||||
@@ -301,6 +341,13 @@ describe('literals (pure)', () => {
|
||||
expect(parseSummary('Tests: 1 failed, 7 passed, 8 total')).toEqual({ ran: 8, failed: 1 });
|
||||
expect(parseSummary('===== 3 passed in 0.1s =====')).toEqual({ ran: 3, failed: 0 });
|
||||
expect(parseSummary('No tests found')).toEqual({ ran: 0, failed: 0 });
|
||||
// Playwright lists the failed tests between its counts, so each count line is read on its own.
|
||||
expect(parseSummary(' 2 failed\n [e2e] › a.spec.ts:9:1 › one\n [e2e] › b.spec.ts:4:1 › two\n 1 flaky\n [e2e] › c.spec.ts:2:1 › three\n 3 skipped\n 135 passed (3.0m)', 'npx playwright test {files}')).toEqual({ ran: 138, failed: 2 });
|
||||
expect(parseSummary(' 137 passed (1.1m)', 'npx playwright test {files}')).toEqual({ ran: 137, failed: 0 });
|
||||
expect(parseSummary(' 3 failed\n [e2e] › a.spec.ts:9:1 › one\n 1 interrupted\n 2 did not run', 'npx playwright test {files}')).toEqual({ ran: 4, failed: 4 });
|
||||
expect(parseSummary(' 5 skipped', 'npx playwright test {files}')).toEqual({ ran: 0, failed: 0 });
|
||||
// A test's own output that looks like a count is not the summary: the last count lines are.
|
||||
expect(parseSummary(' 0 passed\n 1 failed\n [e2e] › a.spec.ts:9:1 › one\n 4 passed (2.0s)', 'npx playwright test {files}')).toEqual({ ran: 5, failed: 1 });
|
||||
expect(parseSummary('ok')).toEqual({ ran: null, failed: null });
|
||||
});
|
||||
test('config: unknown keys, secrets in warn, a runner without {files} and an uncontained glob are PREGATE_CONFIG_INVALID', () => {
|
||||
@@ -440,6 +487,19 @@ describe('gstack-pregate (bin)', () => {
|
||||
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
// Value: protects=a real stale one-character constant still fails literals end to end after the #3492 narrowing, and an incidental
|
||||
// digit in the same file is not what it reports; fails_when=the statement binding drops a named assertion; why_new=only
|
||||
// multi-character values had a bin-level control; seam=none; no_claim=two-character values
|
||||
test('#3492 control: a one-character constant change still fails literals on the test that asserts the old value by name', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, { 'test/nightly/retries.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\n// covers 3 retry shapes\nconst { MAX_RETRIES } = require('../../lib/retry.js');\ntest('retries', () => assert.strictEqual(\n MAX_RETRIES,\n 3,\n));\n" });
|
||||
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" });
|
||||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out).toContain('literals: test/nightly/retries.test.js:7: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
|
||||
expect(r.err).toContain('(PREGATE_LITERALS)');
|
||||
});
|
||||
|
||||
test('GST-344: a history table the branch extends passes literals through the merge-base read; one it does not extend fails', () => {
|
||||
const history = (...ns: number[]) => ["const { MAX_RETRIES } = require('../lib/retry.js');", 'const versions = [', ...ns.map(n => ` [${n}, 'digest-${n}'],`), '];', ''].join('\n');
|
||||
const repo = fixture({ baseFiles: { 'test/history.test.js': history(2, 3) } });
|
||||
@@ -767,6 +827,78 @@ describe('nested JS packages run their own runner from their own directory', ()
|
||||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||||
});
|
||||
|
||||
/** `env` with a `python3` first on PATH whose `-c` probe exits `probeExit` (0: pytest importable, 3: not). */
|
||||
const withPython = (repo: FixtureRepo, env: { PATH: string; BASH_ENV: string }, probeExit: number) => {
|
||||
const dir = path.join(repo.root, `py-${probeExit}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'python3'), `#!/bin/sh\n[ "$1" = "-c" ] && exit ${probeExit}\nexit 3\n`, { mode: 0o755 });
|
||||
return { ...env, PATH: `${dir}:${env.PATH}` };
|
||||
};
|
||||
|
||||
// Value: protects=root-owned tests in two languages each get their language's runner instead of the whole lane going incomplete, and a
|
||||
// language whose runner this machine lacks is named while the other still runs; fails_when=detection again needs one language
|
||||
// across every root file (#3600: `.mjs` + `.py` was PREGATE_INCOMPLETE asking for one `runner` for both); why_new=only
|
||||
// single-language root selections were tested; seam=none; no_claim=the runners themselves (plan only)
|
||||
test('root tests in two languages plan one group per language; a file no runner claims stays unclaimed', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||||
const ctx = ctxFor(repo, withPython(repo, pmShim(repo), 0));
|
||||
const mixed = ['scripts/doku-gate.test.mjs', 'scripts/test_assets.py', 'scripts/version-gate.test.mjs'];
|
||||
expect(planRuns(ctx, mixed)).toEqual({ groups: [
|
||||
{ runner: 'node --test {files}', cwd: '', files: ['scripts/doku-gate.test.mjs', 'scripts/version-gate.test.mjs'] },
|
||||
{ runner: 'python3 -m pytest {files}', cwd: '', files: ['scripts/test_assets.py'] },
|
||||
], unclaimed: [], missing: [], noPubspec: [] });
|
||||
expect(planRuns(ctx, ['scripts/doku-gate.test.mjs', 'test/run.bats'])).toEqual({ groups: [
|
||||
{ runner: 'node --test {files}', cwd: '', files: ['scripts/doku-gate.test.mjs'] },
|
||||
], unclaimed: ['test/run.bats'], missing: [], noPubspec: [] });
|
||||
expect(planRuns(ctxFor(repo, withPython(repo, pmShim(repo), 3)), mixed)).toEqual({ groups: [
|
||||
{ runner: 'node --test {files}', cwd: '', files: ['scripts/doku-gate.test.mjs', 'scripts/version-gate.test.mjs'] },
|
||||
], unclaimed: ['scripts/test_assets.py'], missing: ['pytest'], noPubspec: [] });
|
||||
});
|
||||
|
||||
// Value: protects=a repo-wide bun or vitest claims only JS/TS files, so a root `.py` test beside them gets pytest instead of being handed to
|
||||
// the JS runner; fails_when=bun.lock or a vitest dependency again takes every extension; why_new=the split was only tested with no
|
||||
// repo runner; seam=none; no_claim=the runners themselves (plan only)
|
||||
test('a repo-wide bun or vitest runner claims JS/TS only; a root .py test beside it gets pytest; parts with one runner run together', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||||
const ctx = ctxFor(repo, withPython(repo, pmShim(repo), 0));
|
||||
write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0' } }) + '\n');
|
||||
expect(planRuns(ctx, ['test/a.test.ts', 'scripts/b.test.mjs', 'scripts/test_c.py', 'test/run.bats'])).toEqual({ groups: [
|
||||
{ runner: 'npx vitest run {files}', cwd: '', files: ['test/a.test.ts', 'scripts/b.test.mjs'] },
|
||||
{ runner: 'python3 -m pytest {files}', cwd: '', files: ['scripts/test_c.py'] },
|
||||
], unclaimed: ['test/run.bats'], missing: [], noPubspec: [] });
|
||||
expect(planRuns(ctx, ['test/a.test.ts', 'scripts/b.test.mjs']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: '', files: ['test/a.test.ts', 'scripts/b.test.mjs'] }]);
|
||||
write(repo.work, 'bun.lock', '');
|
||||
expect(planRuns(ctx, ['test/a.test.ts', 'scripts/test_c.py']).groups).toEqual([
|
||||
{ runner: 'bun test {files}', cwd: '', files: ['test/a.test.ts'] },
|
||||
{ runner: 'python3 -m pytest {files}', cwd: '', files: ['scripts/test_c.py'] },
|
||||
]);
|
||||
});
|
||||
|
||||
// Value: protects=a Playwright spec in a repo whose unit runner is vitest runs through `playwright test`, never vitest (which finds
|
||||
// no test files and exits 1 on a diff that touches only specs); fails_when=the repo-wide runner takes every JS/TS test again (#3629);
|
||||
// why_new=no detection knew Playwright; seam=none; no_claim=a real Playwright run (plan only)
|
||||
test('a spec importing @playwright/test runs through playwright test from its package; the other tests keep their runner', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||||
const ctx = ctxFor(repo, pmShim(repo));
|
||||
write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0', '@playwright/test': '^1.48.0' } }) + '\n');
|
||||
write(repo.work, 'tests/e2e/home.spec.ts', "import { expect, test } from '@playwright/test';\ntest('home', async ({ page }) => expect(page).toBeTruthy());\n");
|
||||
write(repo.work, 'app/e2e/app.spec.ts', "import {\n test, // the runner's fixture\n /* it's */ expect,\n} from \"@playwright/test\";\ntest('app', () => {});\n");
|
||||
write(repo.work, 'tests/e2e/tight.spec.ts', "import{test}from'@playwright/test';\ntest('tight', () => {});\n");
|
||||
write(repo.work, 'test/pad.test.js', 'import' + ' '.repeat(5_000) + '\n');
|
||||
write(repo.work, 'test/util.test.js', "import { test } from 'vitest';\nimport type { Page } from '@playwright/test';\nimport type {\n Locator,\n} from '@playwright/test';\nimport { type BrowserContext } from '@playwright/test';\nconst spec = \"import { test } from '@playwright/test';\";\ntest('fixture text and type imports only', () => {});\n");
|
||||
expect(planRuns(ctx, ['tests/e2e/home.spec.ts', 'tests/e2e/tight.spec.ts', 'test/util.test.js', 'test/pad.test.js', 'app/src/sum.test.ts', 'app/e2e/app.spec.ts'])).toEqual({ groups: [
|
||||
{ runner: 'npx vitest run {files}', cwd: '', files: ['test/util.test.js', 'test/pad.test.js'] },
|
||||
{ runner: 'pnpm exec vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] },
|
||||
{ runner: 'npx playwright test --reporter=list {files}', cwd: '', files: ['tests/e2e/home.spec.ts', 'tests/e2e/tight.spec.ts'] },
|
||||
{ runner: 'pnpm exec playwright test --reporter=list {files}', cwd: 'app', files: ['e2e/app.spec.ts'] },
|
||||
], unclaimed: [], missing: [], noPubspec: [] });
|
||||
// A declared runner still takes every file, Playwright specs included.
|
||||
const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bun test {files}' } } as PregateContext;
|
||||
expect(planRuns(declared, ['tests/e2e/home.spec.ts']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['tests/e2e/home.spec.ts'] }]);
|
||||
// A package manager missing from PATH leaves the spec unclaimed naming it.
|
||||
expect(planRuns(ctxFor(repo, { PATH: path.join(repo.root, 'empty-path') }), ['app/e2e/app.spec.ts'])).toEqual({ groups: [], unclaimed: ['app/e2e/app.spec.ts'], missing: ['pnpm'], noPubspec: [] });
|
||||
});
|
||||
|
||||
// Value: protects=repos where root detection works today are unchanged: a nested package that declares no test runner stays with the
|
||||
// root runner from the root, a root vitest stays `npx vitest run` from the root, and a declared runner takes every file;
|
||||
// fails_when=every nested package.json splits a group or the root command changes; why_new=negative controls for the new
|
||||
|
||||
@@ -13,7 +13,7 @@ import { EXCLUDED_BILLING_CATEGORIES, PRICING, estimateCostUsd, hasPricing, pric
|
||||
import * as helperPricing from './helpers/pricing';
|
||||
import {
|
||||
PREREG_SECTIONS, checkPrereg, dryRun, formatEvalCalls, parseEvalCalls, pilotReport, preregTemplate, renderArms, renderDryRun, renderPilotReport, renderSpendTable,
|
||||
resolveArms, settlePilot, startPilot,
|
||||
resolveArms, settlePilot, startPilot, estimateTokens, REASONING_OUTPUT_FLOOR, isSmallTierModel,
|
||||
} from '../lib/eval-plan';
|
||||
import { capyHost, type CapyHost } from './helpers/capy-host';
|
||||
|
||||
@@ -110,6 +110,52 @@ describe('dry run', () => {
|
||||
expect(dryRun({ input: rows, arms: ['gpt-6-astra'], retryPrompt: retry, outputBudget: 100 }).checks.output_budget.ok).toBe(false);
|
||||
expect(dryRun({ input: rows, arms: ['made-up'], retryPrompt: retry }).checks.price_table.missing).toEqual(['made-up']);
|
||||
});
|
||||
|
||||
test('R54: refuses a Haiku- or Sonnet-only plan unless it names a tier question, warns on a mix, and re-costs on frontier prices', () => {
|
||||
const d = tmp();
|
||||
const rows = path.join(d, 'rows.jsonl');
|
||||
fs.writeFileSync(rows, [JSON.stringify({ prompt: 'hello world' }), JSON.stringify({ prompt: 'second row' })].join('\n') + '\n');
|
||||
const retry = path.join(d, 'retry.txt'); fs.writeFileSync(retry, 'retry\n');
|
||||
const small = dryRun({ input: rows, arms: ['claude-haiku-4-5', 'claude-sonnet-5-5#no-gstack'], retryPrompt: retry });
|
||||
expect(small.pass).toBe(false);
|
||||
expect(small.checks.frontier).toMatchObject({ ok: false, small_tier: ['claude-haiku-4-5', 'claude-sonnet-5-5#no-gstack'], tier_question: null });
|
||||
const avgIn = Math.ceil((estimateTokens('hello world') + estimateTokens('second row')) / 2);
|
||||
expect(small.checks.frontier.recost_usd['claude-opus-5-5']).toBe(2 * 2 * estimateCostUsd({ input: avgIn, output: REASONING_OUTPUT_FLOOR }, 'claude-opus-5-5'));
|
||||
expect(small.checks.frontier.recost_usd['gpt-6.1-sol']).toBeGreaterThan(0);
|
||||
expect(renderDryRun(small)).toContain('CHECK: frontier refused');
|
||||
const asked = dryRun({ input: rows, arms: ['claude-haiku-4-5'], retryPrompt: retry, tierQuestion: 'does the diet hold on Haiku' });
|
||||
expect(asked.pass).toBe(true);
|
||||
expect(renderDryRun(asked)).toContain('CHECK: frontier ok (tier question');
|
||||
const mixed = dryRun({ input: rows, arms: ['claude-opus-5-5', 'claude-sonnet-5-5'], retryPrompt: retry });
|
||||
expect(mixed.pass).toBe(true);
|
||||
expect(renderDryRun(mixed)).toContain('CHECK: frontier warn');
|
||||
const frontier = dryRun({ input: rows, arms: ['claude-opus-5-5', 'gpt-6.1-sol#no-gstack'], retryPrompt: retry });
|
||||
expect(frontier.checks.frontier).toMatchObject({ ok: true, small_tier: [] });
|
||||
expect(renderDryRun(frontier)).not.toContain('CHECK: frontier');
|
||||
expect(['claude-opus-5-5', 'gpt-6.1-sol', 'gpt-6-luna', 'gemini-3.8-flash'].some(isSmallTierModel)).toBe(false);
|
||||
const dryFile = (name: string, r: unknown) => { const f = path.join(d, name); fs.writeFileSync(f, JSON.stringify(r)); return f; };
|
||||
const swapped = startPilot({ out: path.join(d, 'swapped'), cap: 5, n: 1, arms: ['claude-haiku-4-5'], dryRun: dryFile('frontier.json', frontier) });
|
||||
expect(swapped.error?.code).toBe('EVAL_DRY_RUN_FAILED');
|
||||
expect(fs.existsSync(path.join(d, 'swapped'))).toBe(false);
|
||||
expect(startPilot({ out: path.join(d, 'asked'), cap: 5, n: 1, arms: ['claude-haiku-4-5'], dryRun: dryFile('asked.json', asked) }).error).toBeUndefined();
|
||||
expect(startPilot({ out: path.join(d, 'mixed'), cap: 5, n: 1, arms: ['claude-opus-5-5', 'claude-sonnet-5-5'], dryRun: dryFile('frontier2.json', frontier) }).error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('R54 CLI: --tier-question refuses another flag as its answer', () => {
|
||||
const host = capyHost();
|
||||
try {
|
||||
const rows = path.join(host.base, 'rows.jsonl'); fs.writeFileSync(rows, JSON.stringify({ prompt: 'hello world' }) + '\n');
|
||||
const retry = path.join(host.base, 'retry.txt'); fs.writeFileSync(retry, 'retry\n');
|
||||
const run = (...extra: string[]) => spawnSync(BIN, ['dry-run', '--input', rows, '--arms', 'claude-haiku-4-5', '--retry-prompt', retry, ...extra], { cwd: host.base, env: host.env, encoding: 'utf8', timeout: 30_000 });
|
||||
const flagAnswer = run('--tier-question', '--json');
|
||||
expect(flagAnswer.status).not.toBe(0);
|
||||
expect(flagAnswer.stderr).toContain('--tier-question needs the question');
|
||||
const refused = run();
|
||||
expect(refused.status).not.toBe(0);
|
||||
expect(refused.stderr).toContain('R54');
|
||||
expect(run('--tier-question', 'does the diet hold on Haiku').status).toBe(0);
|
||||
} finally { host.cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('priced pilot', () => {
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* gstack-flaky quarantine (GSTA-158 P2): registry refusals, renew and release,
|
||||
* `check --base` against git fixtures, expiry, the quarantine attempt budget
|
||||
* under the runner's full green verdict (never a false green), missing ids,
|
||||
* the registry read from the base branch, and `report` over ledgers.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { addEntry, checkRegistry, emptyRegistry, entryProblems, liveQuarantine, readLedgers, readRegistry, registryProblems, releaseEntry, renewEntry, reportLines, suiteCap, writeRegistry, type AddInput, type Registry } from '../lib/quarantine';
|
||||
import { renderResult, runFlaky } from '../lib/flaky-run';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-flaky');
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-quarantine-'));
|
||||
const tmp = () => fs.mkdtempSync(path.join(TMP, 't-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
const NOW = new Date('2026-10-29T00:00:00Z');
|
||||
const DAY = 86_400_000;
|
||||
const at = (days: number) => new Date(NOW.getTime() + days * DAY);
|
||||
const ids = (n: number, file = 'a.test.ts') => Array.from({ length: n }, (_, i) => `${file}::::t${i}`);
|
||||
const add = (reg: Registry, id: string, over: Partial<AddInput> = {}, now = NOW) =>
|
||||
addEntry(reg, id, { owner: 'garry', ledger: 'https://ledger/1', failureClass: 'timing', reason: 'slow CI disk', evidence: ['https://run/1'], suiteIds: ids(100), ...over }, now);
|
||||
const refusal = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return (e as Error).message; } };
|
||||
|
||||
describe('registry: add, renew, release', () => {
|
||||
test('add refuses more than 14 days, no evidence, product or infra class, an unknown id, a duplicate and the cap', () => {
|
||||
const reg = emptyRegistry();
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0', { days: 15 }))).toContain('from 1 to 14');
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0', { evidence: [] }))).toContain('no evidence');
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0', { failureClass: 'product' }))).toContain('class product is refused');
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0', { failureClass: 'infra' }))).toContain('class infra is refused');
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0', { failureClass: 'flaky' }))).toContain('is not one of detector, harness, timing, model-latency');
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::nope'))).toContain('is not a test in the --junit report');
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0', { owner: ' ' }))).toContain('no owner');
|
||||
expect(Object.keys(reg.entries)).toEqual([]);
|
||||
const e = add(reg, 'a.test.ts::::t0');
|
||||
expect(e).toMatchObject({ enteredAt: NOW.toISOString(), expiresAt: at(7).toISOString(), renewals: [], suiteTests: 100 });
|
||||
expect(add(reg, 'a.test.ts::::t1', { days: 14 }).expiresAt).toBe(at(14).toISOString());
|
||||
expect(refusal(() => add(reg, 'a.test.ts::::t0'))).toContain('already has an entry');
|
||||
const small = emptyRegistry();
|
||||
for (let i = 0; i < 5; i++) add(small, `a.test.ts::::t${i}`, { suiteIds: ids(10) });
|
||||
expect(refusal(() => add(small, 'a.test.ts::::t5', { suiteIds: ids(10) }))).toContain('the cap for a 10-test suite is 5');
|
||||
expect(refusal(() => add(small, 'a.test.ts::::t5', { suiteIds: ids(10) }, at(8)))).toBe('ok');
|
||||
});
|
||||
|
||||
test('the cap is 10% of the suite, at least 1, and 5 under 50 tests', () => {
|
||||
expect([1, 10, 49, 50, 99, 100, 1000].map(suiteCap)).toEqual([5, 5, 5, 5, 9, 10, 100]);
|
||||
});
|
||||
|
||||
test('renew once, with new evidence and the owner named again; a second renewal is refused', () => {
|
||||
const reg = emptyRegistry();
|
||||
add(reg, 'a.test.ts::::t0');
|
||||
const id = 'a.test.ts::::t0';
|
||||
expect(refusal(() => renewEntry(reg, id, { evidence: ['https://run/2'] }, at(6)))).toContain('names the owner again');
|
||||
expect(refusal(() => renewEntry(reg, id, { owner: 'garry', evidence: ['https://run/1'] }, at(6)))).toContain('new evidence');
|
||||
expect(refusal(() => renewEntry(reg, id, { owner: 'garry', evidence: ['https://run/2'], days: 8 }, at(6)))).toContain('1 to 7 days');
|
||||
const e = renewEntry(reg, id, { owner: 'garry', evidence: ['https://run/2'] }, at(6));
|
||||
expect(e.expiresAt).toBe(at(14).toISOString());
|
||||
expect(entryProblems(id, e, at(6))).toEqual([]);
|
||||
expect(refusal(() => renewEntry(reg, id, { owner: 'garry', evidence: ['https://run/3'] }, at(13)))).toContain('a second renewal is refused');
|
||||
const expired = emptyRegistry();
|
||||
add(expired, id);
|
||||
expect(renewEntry(expired, id, { owner: 'sam', evidence: ['https://run/9'], days: 3 }, at(10)).expiresAt).toBe(at(13).toISOString());
|
||||
const twice = { ...e, renewals: [...e.renewals, { ...e.renewals[0]!, evidence: ['https://run/3'] }] };
|
||||
expect(entryProblems(id, twice, at(6)).join()).toContain('one renewal is allowed');
|
||||
const farFuture = { ...e, expiresAt: at(400).toISOString(), renewals: [{ ...e.renewals[0]!, renewedAt: at(393).toISOString(), expiresAt: at(400).toISOString() }] };
|
||||
expect(entryProblems(id, farFuture, at(6)).join()).toContain('a renewal is dated before the entry or in the future');
|
||||
expect(liveQuarantine({ schema: 1, entries: { [id]: farFuture }, released: [] }, at(6)).ids.size).toBe(0);
|
||||
expect(entryProblems(id, { ...e, renewals: [null as never] }, at(6)).join()).toContain('a renewal is not an object');
|
||||
});
|
||||
|
||||
test('release moves the entry to released with its reason and PR', () => {
|
||||
const reg = emptyRegistry();
|
||||
add(reg, 'a.test.ts::::t0');
|
||||
expect(refusal(() => releaseEntry(reg, 'a.test.ts::::t0', { reason: 'fixed' }, at(2)))).toContain('--reason and --pr');
|
||||
const r = releaseEntry(reg, 'a.test.ts::::t0', { reason: 'fixed the wait', pr: 'https://github.com/o/r/pull/9' }, at(2));
|
||||
expect(reg.entries).toEqual({});
|
||||
expect(reg.released).toEqual([r]);
|
||||
expect(r).toMatchObject({ id: 'a.test.ts::::t0', owner: 'garry', reason: 'fixed the wait', releasedAt: at(2).toISOString(), enteredAt: NOW.toISOString() });
|
||||
expect(refusal(() => releaseEntry(reg, 'a.test.ts::::t0', { reason: 'x', pr: 'y' }, at(2)))).toContain('no entry to release');
|
||||
});
|
||||
|
||||
test('expired and invalid entries are not quarantined; expiry prints the renew and release commands', () => {
|
||||
const reg = emptyRegistry();
|
||||
add(reg, 'a.test.ts::::t0');
|
||||
expect([...liveQuarantine(reg, at(6)).ids]).toEqual(['a.test.ts::::t0']);
|
||||
const later = liveQuarantine(reg, at(7));
|
||||
expect(later.ids.size).toBe(0);
|
||||
expect(later.notices[0]).toContain('FLAKY_QUARANTINE_EXPIRED: a.test.ts::::t0 expired 2026-11-05 and is not quarantined');
|
||||
expect(later.notices[0]).toContain("renew: gstack-flaky quarantine renew 'a.test.ts::::t0' --owner");
|
||||
expect(later.notices[0]).toContain("release: gstack-flaky quarantine release 'a.test.ts::::t0' --reason");
|
||||
reg.entries['a.test.ts::::t1'] = { ...reg.entries['a.test.ts::::t0']!, expiresAt: at(30).toISOString() };
|
||||
reg.entries['a.test.ts::::t2'] = { ...reg.entries['a.test.ts::::t0']!, failureClass: 'infra' as never };
|
||||
const live = liveQuarantine(reg, at(1));
|
||||
expect([...live.ids]).toEqual(['a.test.ts::::t0']);
|
||||
expect(live.notices.join('\n')).toContain('a.test.ts::::t1 is not quarantined: expires more than 14 days after it entered');
|
||||
expect(live.notices.join('\n')).toContain('a.test.ts::::t2 is not quarantined: class infra is refused');
|
||||
});
|
||||
|
||||
test('check enforces the cap and the entry rules on the whole registry', () => {
|
||||
const reg = emptyRegistry();
|
||||
for (let i = 0; i < 5; i++) add(reg, `a.test.ts::::t${i}`, { suiteIds: ids(10) });
|
||||
expect(registryProblems(reg, NOW)).toEqual([]);
|
||||
reg.entries['a.test.ts::::t5'] = { ...reg.entries['a.test.ts::::t0']! };
|
||||
expect(registryProblems(reg, NOW)).toEqual(['6 live entries, over the cap of 5 for a 10-test suite']);
|
||||
expect(registryProblems(reg, NOW, 60)).toEqual([]);
|
||||
expect(registryProblems(reg, at(8))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const git = (dir: string, ...args: string[]) => {
|
||||
const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout;
|
||||
};
|
||||
|
||||
/** A repository whose main branch quarantines test/a.test.ts::::slow and has released test/a.test.ts::::old; HEAD is a change branch. */
|
||||
function fixtureRepo(now = NOW) {
|
||||
const dir = tmp();
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
fs.mkdirSync(path.join(dir, 'test'));
|
||||
fs.writeFileSync(path.join(dir, 'test', 'a.test.ts'), '// a\n');
|
||||
fs.writeFileSync(path.join(dir, 'test', 'b.test.ts'), '// b\n');
|
||||
const reg = emptyRegistry();
|
||||
const suiteIds = ['test/a.test.ts::::slow', 'test/a.test.ts::::old', 'test/b.test.ts::::other'];
|
||||
add(reg, 'test/a.test.ts::::old', { suiteIds }, now);
|
||||
releaseEntry(reg, 'test/a.test.ts::::old', { reason: 'fixed', pr: '#1' }, now);
|
||||
add(reg, 'test/a.test.ts::::slow', { suiteIds }, now);
|
||||
writeRegistry(dir, reg);
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-qm', 'seed');
|
||||
git(dir, 'checkout', '-qb', 'change');
|
||||
return { dir, reg, suiteIds };
|
||||
}
|
||||
|
||||
const clone = (r: Registry): Registry => JSON.parse(JSON.stringify(r));
|
||||
|
||||
describe('check --base against a git fixture', () => {
|
||||
test('an unchanged registry and a proper release pass', () => {
|
||||
const { dir, reg } = fixtureRepo();
|
||||
expect(checkRegistry(dir, { base: 'main' }, NOW).problems).toEqual([]);
|
||||
const next = clone(reg);
|
||||
releaseEntry(next, 'test/a.test.ts::::slow', { reason: 'fixed', pr: '#2' }, at(1));
|
||||
writeRegistry(dir, next);
|
||||
expect(checkRegistry(dir, { base: 'main' }, at(1)).problems).toEqual([]);
|
||||
});
|
||||
|
||||
test('a deleted entry, a removed release record and rewritten history are refused', () => {
|
||||
const { dir, reg } = fixtureRepo();
|
||||
const deleted = clone(reg);
|
||||
delete deleted.entries['test/a.test.ts::::slow'];
|
||||
writeRegistry(dir, deleted);
|
||||
expect(checkRegistry(dir, { base: 'main' }, NOW).problems.join('\n')).toContain('test/a.test.ts::::slow: removed without a release record');
|
||||
const unreleased = clone(reg);
|
||||
unreleased.released = [];
|
||||
writeRegistry(dir, unreleased);
|
||||
expect(checkRegistry(dir, { base: 'main' }, NOW).problems.join('\n')).toContain('test/a.test.ts::::old: its release record of');
|
||||
const restarted = clone(reg);
|
||||
restarted.entries['test/a.test.ts::::slow']!.enteredAt = at(1).toISOString();
|
||||
writeRegistry(dir, restarted);
|
||||
expect(checkRegistry(dir, { base: 'main' }, at(1)).problems.join('\n')).toContain('enteredAt changed');
|
||||
});
|
||||
|
||||
test('an entry added in the same change that edits its test file is refused; without the edit it passes', () => {
|
||||
const { dir, reg, suiteIds } = fixtureRepo();
|
||||
const next = clone(reg);
|
||||
add(next, 'test/b.test.ts::::other', { suiteIds });
|
||||
writeRegistry(dir, next);
|
||||
expect(checkRegistry(dir, { base: 'main' }, NOW).problems).toEqual([]);
|
||||
fs.writeFileSync(path.join(dir, 'test', 'b.test.ts'), '// b, now broken\n');
|
||||
git(dir, 'commit', '-qam', 'break b and quarantine it');
|
||||
expect(checkRegistry(dir, { base: 'main' }, NOW).problems).toEqual([
|
||||
'test/b.test.ts::::other: added in the same change that edits test/b.test.ts; a change cannot quarantine the test it touches (land the entry on its own, or fix the test)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a non-ASCII test path is matched too (git would quote it by default)', () => {
|
||||
const { dir, reg } = fixtureRepo();
|
||||
fs.writeFileSync(path.join(dir, 'test', 'über.test.ts'), '// ü\n');
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-qm', 'add über');
|
||||
const next = clone(reg);
|
||||
add(next, 'test/über.test.ts::::t', { suiteIds: ['test/über.test.ts::::t', 'test/a.test.ts::::slow'] });
|
||||
writeRegistry(dir, next);
|
||||
expect(checkRegistry(dir, { base: 'main' }, NOW).problems.join('\n')).toContain('test/über.test.ts::::t: added in the same change that edits test/über.test.ts');
|
||||
});
|
||||
|
||||
test('the bin: check exits 1 with QUARANTINE_REFUSED lines, and an unknown base exits 3', () => {
|
||||
const { dir, reg } = fixtureRepo(new Date());
|
||||
const deleted = clone(reg);
|
||||
delete deleted.entries['test/a.test.ts::::slow'];
|
||||
writeRegistry(dir, deleted);
|
||||
const r = spawnSync(BIN, ['quarantine', 'check', '--base', 'main'], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stdout).toContain('QUARANTINE_REFUSED: test/a.test.ts::::slow: removed without a release record');
|
||||
expect(r.stdout).toContain('verdict=fail');
|
||||
const missing = spawnSync(BIN, ['quarantine', 'check', '--base', 'no-such-branch'], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
|
||||
expect([missing.status, missing.stderr.includes('(FLAKY_QUARANTINE_BASE)')]).toEqual([3, true]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A fake runner: the attempt number decides the report, exit status, output and signal. */
|
||||
function fakeRun(reports: (n: number, argv: string[]) => string | null, exits: (n: number) => number, outputs: (n: number) => string = () => 'Ran 3 tests across 1 file. [3ms]\n', signals: (n: number) => string | undefined = () => undefined) {
|
||||
const calls: string[][] = [];
|
||||
const spawn = async (argv: string[], _env: NodeJS.ProcessEnv, cwd: string) => {
|
||||
calls.push(argv);
|
||||
const xml = reports(calls.length, argv);
|
||||
if (xml !== null) fs.writeFileSync(path.join(cwd, 'r.xml'), xml);
|
||||
const signal = signals(calls.length);
|
||||
return { exit: exits(calls.length), output: outputs(calls.length), ...(signal ? { signal } : {}) };
|
||||
};
|
||||
return { calls, spawn };
|
||||
}
|
||||
|
||||
const suite = (cases: string, file = 'a.test.ts') => `<testsuites name="bun test"><testsuite name="${file}" file="${file}">${cases}</testsuite></testsuites>`;
|
||||
const pass = (name: string) => `<testcase name="${name}" classname="" file="a.test.ts"/>`;
|
||||
const fail = (name: string) => `<testcase name="${name}" classname="" file="a.test.ts"><failure message="boom"/></testcase>`;
|
||||
const Q = new Set(['a.test.ts::::q']);
|
||||
const run = (f: ReturnType<typeof fakeRun>, extra: Partial<Parameters<typeof runFlaky>[0]> = {}) =>
|
||||
runFlaky({ command: ['bun', 'test'], junit: 'r.xml', maxReruns: 3, cwd: tmp(), spawn: f.spawn, env: {}, now: () => NOW, quarantined: Q, ...extra });
|
||||
|
||||
describe('the quarantine attempt budget under the full green verdict', () => {
|
||||
test('a quarantined test that fails all 3 attempts is a hard break', async () => {
|
||||
const f = fakeRun(() => suite(pass('a') + fail('q')), () => 1);
|
||||
const r = await run(f);
|
||||
expect(f.calls).toHaveLength(3);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.outcomes).toMatchObject([{ status: 'hard-break', attempts: ['fail', 'fail', 'fail'], reason: 'failed all 3 attempts: a hard break, not a flake' }]);
|
||||
expect(r.ledger.map(l => [l.kind, l.id, l.rerun_scope])).toEqual([['hard-break', 'a.test.ts::::q', 'command']]);
|
||||
expect(renderResult(r, '/l').at(-1)).toBe('FLAKY_RESULT: tests=2 failed=1 flaky=0 quarantined=1 reruns=2 command_exit=1 verdict=fail ledger=/l');
|
||||
});
|
||||
|
||||
test('it passes on attempt 3 only on the runner\'s full green verdict, listed as quarantined and not as a pass', async () => {
|
||||
const f = fakeRun(n => suite(pass('a') + (n < 3 ? fail('q') : pass('q'))), n => n < 3 ? 1 : 0);
|
||||
const r = await run(f);
|
||||
expect(f.calls).toHaveLength(3);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.outcomes).toMatchObject([{ status: 'quarantined-pass', attempts: ['fail', 'fail', 'pass'] }]);
|
||||
expect(r.ledger[0]).toMatchObject({ kind: 'quarantined-pass', attempts: ['fail', 'fail', 'pass'] });
|
||||
expect(renderResult(r, '/l')).toEqual(['FLAKY: quarantined-pass a.test.ts::::q attempts=fail,fail,pass', 'FLAKY_RERUN: scope=command (the default; --rerun-scope test reruns the failed test alone)',
|
||||
'FLAKY_RESULT: tests=2 failed=0 flaky=0 quarantined=1 reruns=2 command_exit=1 verdict=pass ledger=/l']);
|
||||
});
|
||||
|
||||
test('false-green regression: exit 1 from outside the report never turns green because only a quarantined test failed', async () => {
|
||||
const greenReportLaterExit1 = fakeRun(n => suite(pass('a') + (n === 1 ? fail('q') : pass('q'))), () => 1);
|
||||
const a = await run(greenReportLaterExit1);
|
||||
expect(greenReportLaterExit1.calls).toHaveLength(2);
|
||||
expect(a.exitCode).toBe(1);
|
||||
expect(a.outcomes).toMatchObject([{ status: 'quarantined-fail', attempts: ['fail', 'pass'], reason: 'rerun: the command exited 1' }]);
|
||||
const alwaysExit1 = fakeRun(() => suite(pass('a') + fail('q')), () => 1, () => 'All files | % Funcs | % Lines |\nRan 2 tests across 1 file.\n');
|
||||
const b = await run(alwaysExit1, { rerunScope: 'test' });
|
||||
expect(alwaysExit1.calls.every(c => c.join(' ') === 'bun test')).toBe(true);
|
||||
expect([b.exitCode, b.outcomes[0]!.status]).toEqual([1, 'hard-break']);
|
||||
});
|
||||
|
||||
test('the bin keeps a coverage-style exit 1 red while the only reported failure is quarantined', () => {
|
||||
const dir = tmp();
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
const reg = emptyRegistry();
|
||||
add(reg, 'a.test.ts::::q', { suiteIds: ['a.test.ts::::a', 'a.test.ts::::q'] }, new Date());
|
||||
writeRegistry(dir, reg);
|
||||
const runner = path.join(dir, 'runner.sh');
|
||||
fs.writeFileSync(runner, ['#!/usr/bin/env bash', 'echo x >> count', `printf '%s' '${suite(pass('a') + fail('q'))}' > r.xml`, 'exit 1'].join('\n'), { mode: 0o755 });
|
||||
const r = spawnSync(BIN, ['run', '--junit', 'r.xml', '--ledger', path.join(dir, 'l.jsonl'), '--', runner], { cwd: dir, encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GITHUB_ACTIONS: '', GITHUB_STEP_SUMMARY: '' } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stdout).toContain('FLAKY: hard-break a.test.ts::::q attempts=fail,fail,fail');
|
||||
expect(r.stdout).toContain('quarantined=1 reruns=2 command_exit=1 verdict=fail');
|
||||
expect(fs.readFileSync(path.join(dir, 'count'), 'utf-8').trim().split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('non-quarantined failures keep one rerun; a new non-quarantined failure ends the budget', async () => {
|
||||
const both = fakeRun(n => suite((n === 1 ? fail('a') : fail('a')) + (n === 1 ? fail('q') : pass('q'))), () => 1);
|
||||
const r = await run(both);
|
||||
expect(both.calls).toHaveLength(2);
|
||||
expect(r.outcomes.map(o => [o.test.id, o.status])).toEqual([['a.test.ts::::a', 'rerun-fail'], ['a.test.ts::::q', 'quarantined-fail']]);
|
||||
const newFailure = fakeRun(n => suite((n === 1 ? pass('a') : fail('a')) + fail('q')), () => 1);
|
||||
const s = await run(newFailure);
|
||||
expect(newFailure.calls).toHaveLength(2);
|
||||
expect([s.exitCode, s.outcomes[0]!.status]).toEqual([1, 'quarantined-fail']);
|
||||
});
|
||||
|
||||
test('a flaky test and a quarantined test both clear on the attempt that is fully green', async () => {
|
||||
const f = fakeRun(n => suite((n === 1 ? fail('a') : pass('a')) + (n < 3 ? fail('q') : pass('q'))), n => n < 3 ? 1 : 0);
|
||||
const r = await run(f);
|
||||
expect(f.calls).toHaveLength(3);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.outcomes.map(o => [o.status, o.attempts.join(',')])).toEqual([['flaky-pass', 'fail,pass,pass'], ['quarantined-pass', 'fail,fail,pass']]);
|
||||
});
|
||||
|
||||
test('a killed attempt or a missing first-run test ends the budget red', async () => {
|
||||
const killed = fakeRun(() => suite(pass('a') + fail('q')), n => n === 1 ? 1 : 128, undefined, n => n === 2 ? 'SIGTERM' : undefined);
|
||||
expect((await run(killed)).exitCode).toBe(1);
|
||||
expect(killed.calls).toHaveLength(2);
|
||||
const shrunk = fakeRun(n => n === 1 ? suite(pass('a') + fail('q')) : suite(fail('q')), () => 1);
|
||||
expect((await run(shrunk)).exitCode).toBe(1);
|
||||
expect(shrunk.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a quarantined test that passes is listed, ledgered for the exit rule, and the run is green', async () => {
|
||||
const r = await run(fakeRun(() => suite(pass('a') + pass('q')), () => 0));
|
||||
expect(r).toMatchObject({ exitCode: 0, reruns: 0, quarantined: 1 });
|
||||
expect(r.ledger.map(l => [l.kind, l.attempts])).toEqual([['quarantined-pass', ['pass']]]);
|
||||
expect(renderResult(r, '/l').at(-1)).toContain('failed=0 flaky=0 quarantined=1');
|
||||
});
|
||||
|
||||
test('quarantined failures do not count against --max-reruns', async () => {
|
||||
const f = fakeRun(n => suite(pass('a') + (n === 1 ? fail('q') : pass('q'))), n => n === 1 ? 1 : 0);
|
||||
expect((await run(f, { maxReruns: 0 })).exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('--rerun-scope test gives the quarantined test the same budget on the targeted path', async () => {
|
||||
const f = fakeRun((n, argv) => argv.length === 2 ? suite(pass('a') + fail('q')) : suite(n < 3 ? fail('q') : pass('q')), n => n < 3 ? 1 : 0);
|
||||
const r = await run(f, { rerunScope: 'test' });
|
||||
expect(f.calls.slice(1)).toEqual([['bun', 'test', 'a.test.ts', '--test-name-pattern', '^q$'], ['bun', 'test', 'a.test.ts', '--test-name-pattern', '^q$']]);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.outcomes).toMatchObject([{ status: 'quarantined-pass', attempts: ['fail', 'fail', 'pass'] }]);
|
||||
const notQ = fakeRun((n, argv) => argv.length === 2 ? suite(pass('q') + fail('a')) : suite(fail('a')), () => 1);
|
||||
expect((await run(notQ, { rerunScope: 'test' })).exitCode).toBe(1);
|
||||
expect(notQ.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('an expired entry gives no budget: one rerun, as for any test', async () => {
|
||||
const reg = emptyRegistry();
|
||||
add(reg, 'a.test.ts::::q', { suiteIds: ['a.test.ts::::q'] });
|
||||
const f = fakeRun(n => suite(pass('a') + (n < 3 ? fail('q') : pass('q'))), n => n < 3 ? 1 : 0);
|
||||
const r = await run(f, { quarantined: liveQuarantine(reg, at(8)).ids });
|
||||
expect(f.calls).toHaveLength(2);
|
||||
expect([r.exitCode, r.outcomes[0]!.status, r.quarantined]).toEqual([1, 'rerun-fail', 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing quarantined ids', () => {
|
||||
test('a quarantined id missing from a run that included its file blocks', async () => {
|
||||
const f = fakeRun(() => suite(pass('a') + pass('b')), () => 0);
|
||||
const r = await run(f, { quarantined: new Set(['a.test.ts::::gone']) });
|
||||
expect(f.calls).toHaveLength(1);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.outcomes).toMatchObject([{ status: 'quarantined-fail', reason: 'quarantined, and missing from a run that included its file' }]);
|
||||
expect(r.ledger[0]).toMatchObject({ kind: 'quarantined-fail', id: 'a.test.ts::::gone' });
|
||||
});
|
||||
|
||||
test('a run that did not include its file (a shard, a diff selection) does not block', async () => {
|
||||
const r = await run(fakeRun(() => suite(pass('a')), () => 0), { quarantined: new Set(['b.test.ts::::gone', 'a.test.ts:: chromium ::x']) });
|
||||
expect(r).toMatchObject({ exitCode: 0, outcomes: [], quarantined: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the registry read from the base branch', () => {
|
||||
test('readRegistry --base sees the base copy, not the change\'s own entry', () => {
|
||||
const { dir, reg, suiteIds } = fixtureRepo();
|
||||
const next = clone(reg);
|
||||
add(next, 'test/b.test.ts::::other', { suiteIds });
|
||||
writeRegistry(dir, next);
|
||||
expect(Object.keys(readRegistry(dir).entries).sort()).toEqual(['test/a.test.ts::::slow', 'test/b.test.ts::::other']);
|
||||
expect(Object.keys(readRegistry(dir, 'main').entries)).toEqual(['test/a.test.ts::::slow']);
|
||||
});
|
||||
|
||||
test('the bin: a change that quarantines its own failing test gets no budget with --base', () => {
|
||||
const dir = tmp();
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
git(dir, 'commit', '-q', '--allow-empty', '-m', 'seed');
|
||||
git(dir, 'checkout', '-qb', 'change');
|
||||
fs.writeFileSync(path.join(dir, 'seed.xml'), suite(pass('a') + pass('b')));
|
||||
const addRun = spawnSync(BIN, ['quarantine', 'add', 'a.test.ts::::b', '--owner', 'garry', '--ledger', 'https://l/1', '--class', 'timing', '--reason', 'slow', '--evidence', 'https://run/1', '--junit', 'seed.xml'], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
|
||||
expect([addRun.status, addRun.stdout.startsWith('QUARANTINE_ADDED: a.test.ts::::b owner=garry class=timing')]).toEqual([0, true]);
|
||||
const runner = path.join(dir, 'runner.sh');
|
||||
fs.writeFileSync(runner, [
|
||||
'#!/usr/bin/env bash',
|
||||
'n=$(cat count 2>/dev/null || echo 0); echo $((n+1)) > count',
|
||||
`if [ "$n" -lt 2 ]; then printf '%s' '${suite(pass('a') + fail('b'))}' > r.xml; exit 1; fi`,
|
||||
`printf '%s' '${suite(pass('a') + pass('b'))}' > r.xml`,
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const flaky = (extra: string[]) => {
|
||||
fs.rmSync(path.join(dir, 'count'), { force: true });
|
||||
return spawnSync(BIN, ['run', '--junit', 'r.xml', '--ledger', path.join(dir, 'l.jsonl'), ...extra, '--', runner], { cwd: dir, encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GITHUB_ACTIONS: '', GITHUB_STEP_SUMMARY: '' } });
|
||||
};
|
||||
const own = flaky([]);
|
||||
expect([own.status, own.stdout.includes('FLAKY: quarantined-pass a.test.ts::::b attempts=fail,fail,pass')]).toEqual([0, true]);
|
||||
const fromBase = flaky(['--base', 'main']);
|
||||
expect([fromBase.status, fromBase.stdout.includes('FLAKY: rerun-fail a.test.ts::::b')]).toEqual([1, true]);
|
||||
expect(fs.readFileSync(path.join(dir, 'count'), 'utf-8').trim()).toBe('2');
|
||||
const badBase = flaky(['--base', 'no-such-branch']);
|
||||
expect([badBase.status, badBase.stderr.includes('(FLAKY_QUARANTINE_BASE)')]).toEqual([3, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quarantine CLI and report', () => {
|
||||
test('add, renew, release through the bin; refusals exit 1 and usage exits 2', () => {
|
||||
const dir = tmp();
|
||||
fs.writeFileSync(path.join(dir, 'seed.xml'), suite(pass('a') + pass('b')));
|
||||
const q = (...args: string[]) => spawnSync(BIN, ['quarantine', ...args], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
|
||||
const base = ['--owner', 'garry', '--ledger', 'https://l/1', '--reason', 'slow', '--evidence', 'https://run/1', '--junit', 'seed.xml'];
|
||||
const product = q('add', 'a.test.ts::::b', ...base, '--class', 'product');
|
||||
expect([product.status, product.stderr.includes('class product is refused'), product.stderr.includes('(FLAKY_QUARANTINE_REFUSED)')]).toEqual([1, true, true]);
|
||||
expect(q('add', 'a.test.ts::::b', ...base, '--class', 'harness').status).toBe(0);
|
||||
expect(q('renew', 'a.test.ts::::b', '--owner', 'garry', '--evidence', 'https://run/2').status).toBe(0);
|
||||
expect(q('renew', 'a.test.ts::::b', '--owner', 'garry', '--evidence', 'https://run/3').status).toBe(1);
|
||||
expect(q('check').stdout).toContain('QUARANTINE_CHECK: live=1 cap=5 base=none verdict=pass');
|
||||
expect(q('release', 'a.test.ts::::b', '--reason', 'fixed', '--pr', '#7').status).toBe(0);
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(dir, '.gstack', 'quarantine.json'), 'utf-8')) as Registry;
|
||||
expect([Object.keys(saved.entries), saved.released.map(r => [r.id, r.pr, r.renewals.length])]).toEqual([[], [['a.test.ts::::b', '#7', 1]]]);
|
||||
expect(q('add', 'a.test.ts::::b', '--bogus', 'x').status).toBe(2);
|
||||
expect(q('frobnicate').status).toBe(2);
|
||||
});
|
||||
|
||||
test('report: expired first, then soonest-expiring; the exit rule needs 10 consecutive clean passes from the ledgers', () => {
|
||||
const reg = emptyRegistry();
|
||||
add(reg, 'a.test.ts::::t0', { days: 14 });
|
||||
add(reg, 'a.test.ts::::t1', { days: 2 });
|
||||
add(reg, 'a.test.ts::::t2', { days: 1 }, at(-5));
|
||||
const without = reportLines(reg, NOW);
|
||||
expect(without.map(l => l.split(' ').slice(0, 2).join(' '))).toEqual(['FLAKY_QUARANTINE_EXPIRED: a.test.ts::::t2', 'QUARANTINE: a.test.ts::::t1', 'QUARANTINE: a.test.ts::::t0', 'QUARANTINE_REPORT: live=2']);
|
||||
expect(without[1]).toContain('days_left=2');
|
||||
expect(without[1]).toContain("EXPIRING (renew: gstack-flaky quarantine renew 'a.test.ts::::t1'");
|
||||
expect(without.at(-1)).toBe('QUARANTINE_REPORT: live=2 expired=1 expiring=1 ready=unchecked exit_rule=not checked (no --ledger given)');
|
||||
const dir = tmp();
|
||||
fs.mkdirSync(path.join(dir, 'run-1', 'flaky-junit-ledger-e2e'), { recursive: true });
|
||||
const lineFor = (id: string, i: number, kind = 'quarantined-pass', attempts = ['pass']) => JSON.stringify({ ts: new Date(NOW.getTime() + i * 1000).toISOString(), kind, id, attempts });
|
||||
const t0 = [lineFor('a.test.ts::::t0', 0, 'hard-break', ['fail', 'fail', 'fail']), ...Array.from({ length: 10 }, (_, i) => lineFor('a.test.ts::::t0', i + 1))];
|
||||
const t1 = [...Array.from({ length: 9 }, (_, i) => lineFor('a.test.ts::::t1', i)), lineFor('a.test.ts::::t1', 9, 'quarantined-pass', ['fail', 'pass'])];
|
||||
fs.writeFileSync(path.join(dir, 'run-1', 'flaky-junit-ledger-e2e', 'flaky-junit-ledger.jsonl'), [...t0, ...t1, 'not json'].join('\n') + '\n');
|
||||
const ledgers = readLedgers([dir]);
|
||||
expect([ledgers.records.length, ledgers.skipped]).toEqual([21, 1]);
|
||||
const withLedger = reportLines(reg, NOW, ledgers.records);
|
||||
expect(withLedger.find(l => l.includes('t0 '))).toContain("recent=pass,pass,pass,pass,pass clean_streak=10 READY_TO_RELEASE: gstack-flaky quarantine release 'a.test.ts::::t0'");
|
||||
expect(withLedger.find(l => l.includes('t1 '))).toContain('recent=pass,pass,pass,pass,flaky clean_streak=0');
|
||||
expect(withLedger.at(-1)).toBe('QUARANTINE_REPORT: live=2 expired=1 expiring=1 ready=1 exit_rule=checked (10 consecutive clean passes)');
|
||||
});
|
||||
|
||||
test('every FLAKY_QUARANTINE_ code is in the docs table, and the docs say the registries share no schema with CASE_QUARANTINE', () => {
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'flaky.md'), 'utf-8');
|
||||
const src = fs.readFileSync(path.join(ROOT, 'lib', 'quarantine.ts'), 'utf-8') + fs.readFileSync(path.join(ROOT, 'bin', 'gstack-flaky'), 'utf-8');
|
||||
const codes = [...new Set(src.match(/FLAKY_QUARANTINE_[A-Z_]+/g))];
|
||||
expect(codes.length).toBeGreaterThan(4);
|
||||
expect(codes.filter(c => !doc.includes(`| \`${c}\` |`))).toEqual([]);
|
||||
expect(doc).toContain('CASE_QUARANTINE');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDiff, classifyPath, dataCandidate, depthFor, gateScanNeeded, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
|
||||
import { summarizeReviewLoop } from '../lib/review-scope';
|
||||
import { sectionTexts } from '../lib/review-brief';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const none = scanNumbers({});
|
||||
@@ -70,6 +72,123 @@ describe('nextStep: the transition table', () => {
|
||||
test('a clean fix-check is done', () => expect(nextStep({ fullPasses: 1, last: 'fix-check' }).step).toBe('done'));
|
||||
});
|
||||
|
||||
// Value: protects=on gate class Step 11's adversarial, outside and targeted voices run before any owner exit the round cap reaches, and a recorded owner grant dispatches its one scoped fix-check after round 2, once; fails_when=a gate-class round-2 blocker fix stops before Step 11 (C6-36 after-opus: next=stop, no voice ran), or a recorded grant still stops (C6-36 after-opus-grant: the lane hand-dispatched the fix-check);
|
||||
// why_new=GST-967; seam=nextStep, planReview; no_claim=the voices' or the fix-check's verdicts
|
||||
describe('GST-967: the round cap limits fix rounds, not the gate-class voices; a recorded grant runs its fix-check', () => {
|
||||
const capStop = { fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true } as const;
|
||||
test('a gate-class round-2 blocker fix runs Step 11\'s voices before the owner stop, then stops', () => {
|
||||
expect(nextStep({ ...capStop, gate: true })).toMatchObject({ step: 'voices', reason: expect.stringContaining("Step 11's voices") });
|
||||
for (const o of [{ blockingOpen: true }, { fixedBlocking: false, fixedBelow: true, touchedGate: true }, { fullPasses: 1, last: 'fix-check' as const }, { fullPasses: 1, blockingOpen: true }]) expect(nextStep({ ...capStop, gate: true, ...o }).step).toBe('voices');
|
||||
expect(nextStep({ ...capStop, gate: true, adversarialRan: true }).step).toBe('stop');
|
||||
expect(nextStep(capStop).step).toBe('stop');
|
||||
});
|
||||
test('C6-36 run 1 replanned: the plan lists Step 11\'s three voices, and once they ran the stop stands', () => {
|
||||
const h = { ...capStop, touchedGate: true };
|
||||
const p = planReview(base({ gateTagged: 1, targeted: { classes: ['gate'] }, history: h }));
|
||||
expect([p.class, p.next.step, roles(p)]).toEqual(['gate', 'voices', ['adversarial-native:adversarial:full', 'outside-voice:outside-voice:host', 'targeted:targeted:full']]);
|
||||
expect(planReview(base({ gateTagged: 1, adversarialRan: true, targeted: { classes: ['gate'] }, history: h })).next.step).toBe('stop');
|
||||
});
|
||||
test('a recorded grant dispatches one scoped fix-check after a round-2 full pass, after the gate-class voices, and never a second', () => {
|
||||
const g = { ...capStop, granted: 'a-feb4aba31633' };
|
||||
expect(nextStep(g)).toMatchObject({ step: 'fix-check', reason: expect.stringContaining('a-feb4aba31633') });
|
||||
expect(roles(planReview(base({ history: g })))).toEqual(['fix-check:fix-check:full']);
|
||||
expect(nextStep({ ...g, gate: true }).step).toBe('voices');
|
||||
expect(nextStep({ ...g, gate: true, adversarialRan: true }).step).toBe('fix-check');
|
||||
expect(nextStep({ ...g, fixedBlocking: false, fixedBelow: true, touchedGate: true }).step).toBe('fix-check');
|
||||
expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBlocking: true, granted: 'a-1' }).step).toBe('stop');
|
||||
expect(nextStep({ ...g, blockingOpen: true }).step).toBe('stop');
|
||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'fix-check', granted: 'a-1' }).step).toBe('done');
|
||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'fix-check', fixedBlocking: true, granted: 'a-1' }).step).toBe('stop');
|
||||
});
|
||||
test('docs/review-policy.md\'s transitions table and rule 3 say both', () => {
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'review-policy.md'), 'utf8');
|
||||
const table = doc.slice(doc.indexOf('<a id="transitions"></a>'), doc.indexOf('A **fix-check** is one reviewer'));
|
||||
expect(table).toContain("| a stop (any row above) on gate class before Step 11's voices ran | Step 11's voices (adversarial, outside, targeted), then ask again with `--adversarial-ran` |");
|
||||
expect(table).toContain('| a round-2 full pass that stops for a fixed blocking finding or a gate-class edit, with the owner\'s recorded grant (`--grant <approval id>`) | one scoped fix-check (round 3), after Step 11 on gate class |');
|
||||
const rule3 = doc.slice(doc.indexOf('3. **Two rounds is the cap**'), doc.indexOf('<a id="transitions"></a>')).replace(/\s+/g, ' ');
|
||||
expect(rule3).toContain("The cap limits fix rounds, not Step 11's voices");
|
||||
expect(rule3).toContain('`gstack-review-plan --grant <approval id> --range-base <round 2\'s PASS_HEAD>`');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GST-967's safety property, as a permanent invariant over the transitions table (Captain, 2026-11-02: the invariant is the
|
||||
* gate, a reader the second check). Every state: passes 0-3, each last step, every mix of the four fix flags, gate class or
|
||||
* not, Step 11 run or not, grant recorded or not.
|
||||
*/
|
||||
describe('GST-967 invariant: no gate-class owner exit before Step 11\'s voices; a recorded grant always gets its fix-check', () => {
|
||||
// Value: protects=a gate-class plan never stops (the exit where the owner may ship with open notes) before Step 11's adversarial, outside and targeted voices ran, voices is planned only then and dispatches all three, and a recorded grant always turns the stop after a round-2 full pass with a fixed finding into exactly one fix-check while changing nothing else, so it never repeats; fails_when=any state breaks one of those, so a later edit to nextStep cannot fail open;
|
||||
// why_new=GST-967 (three earlier fixes failed open and a reviewer caught each after the fact); seam=nextStep, planReview; no_claim=the parent following NEXT
|
||||
test('every state of the table holds the property', () => {
|
||||
let states = 0, voices = 0, grants = 0;
|
||||
for (const fullPasses of [0, 1, 2, 3]) for (const last of ['none', 'full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++)
|
||||
for (const gate of [false, true]) for (const adversarialRan of [false, true]) {
|
||||
const h = { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8), gate, adversarialRan };
|
||||
const plain = nextStep(h), granted = nextStep({ ...h, granted: 'a-1' });
|
||||
const ungatedStop = nextStep({ ...h, gate: false }).step === 'stop';
|
||||
states++;
|
||||
for (const n of [plain, granted]) {
|
||||
if (gate && !adversarialRan) expect(n.step).not.toBe('stop');
|
||||
if (n.step === 'voices') expect([gate, adversarialRan, ungatedStop]).toEqual([true, false, true]);
|
||||
}
|
||||
if (plain.step === 'voices') voices++;
|
||||
const owed = ungatedStop && !h.blockingOpen && last === 'full' && fullPasses === FULL_PASS_CAP && (!gate || adversarialRan);
|
||||
if (owed) grants++;
|
||||
expect([granted.step === 'fix-check' && plain.step !== 'fix-check', granted.step === plain.step]).toEqual([owed, !owed]);
|
||||
if (owed) expect(granted.reason).toContain('a-1');
|
||||
}
|
||||
expect(states).toBe(4 * 3 * 16 * 2 * 2);
|
||||
expect([voices > 0, grants > 0]).toEqual([true, true]);
|
||||
const gatePlan = (o: Partial<PlanInput>) => planReview(base({ gateTagged: 1, targeted: { classes: ['gate'] }, ...o }));
|
||||
for (const fullPasses of [1, 2]) for (const last of ['full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) {
|
||||
const p = gatePlan({ history: { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8), granted: bits & 1 ? 'a-1' : undefined } });
|
||||
expect(p.next.step).not.toBe('stop');
|
||||
if (p.next.step === 'voices') expect(p.dispatches.map(d => d.role)).toEqual(['adversarial-native', 'outside-voice', 'targeted']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/review-packets-removed.test.ts).
|
||||
* On a 1,440-case grid (five diffs: light, standard, gate, docs, tests; passes 0-2; last none, full or fix-check; every mix of
|
||||
* the four fix flags; Step 11 run or not), main's planReview at 1022f4f (v1.92.81.0) hashes to MAIN_PLANS. The branch's plan,
|
||||
* with each voices step read back as main's stop (its reason is the parenthesised tail, no dispatch), hashes to the same value,
|
||||
* and every voices step is a gate-class stop before Step 11 that adds exactly Step 11's three voices. No grant is passed (main
|
||||
* takes none). The brief bodies lib/review-brief.ts cuts from the ship sections hash to MAIN_SECTIONS, main's value.
|
||||
*/
|
||||
const MAIN_PLANS = '9d13d6bae86bfc9207ce7e0cec8da5b33db2518c1653e8e009160a98b0f04c89';
|
||||
const MAIN_SECTIONS = 'a2b999aa2de2c4cdbb953aeb0a4fac6a67e01a2c5d74121e191b95781e68e345';
|
||||
describe('GST-967 dispatch-superset no-loss check', () => {
|
||||
// Value: protects=the no-loss rule for review-depth changes: GST-967 drops, renames and re-efforts no reviewer main dispatches on any grid case, and changes no brief body; fails_when=a non-stop plan changes, a voices step appears off gate class or after Step 11, it dispatches anything but the three voices, or a brief body moves;
|
||||
// why_new=GST-967; seam=planReview, sectionTexts; no_claim=what the added voices find (they only add coverage)
|
||||
test('the grid matches main except gate-class stops before Step 11, which add only the three voices; brief bodies are main\'s', () => {
|
||||
const sha = (x: unknown) => createHash('sha256').update(JSON.stringify(x)).digest('hex');
|
||||
const fixtures: Record<string, Partial<PlanInput>> = {
|
||||
light: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120 },
|
||||
standard: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, floor: 'standard' },
|
||||
gate: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true' }, gateTagged: 1, diffLines: 120, targeted: { classes: ['gate'] } },
|
||||
docs: { paths: ['README.md'], scope: {}, gateTagged: null, diffLines: 30 },
|
||||
tests: { paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' }, gateTagged: 0, diffLines: 40 },
|
||||
};
|
||||
const rows: unknown[] = [];
|
||||
let voices = 0;
|
||||
for (const [name, f] of Object.entries(fixtures)) for (const fullPasses of [0, 1, 2]) for (const last of ['none', 'full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) for (const adversarialRan of [false, true]) {
|
||||
const history = { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8) };
|
||||
const p = planReview({ ...f, numbers: none, history, adversarialRan } as PlanInput);
|
||||
let { step, reason } = p.next;
|
||||
let dispatches = p.dispatches;
|
||||
if (step === 'voices') {
|
||||
voices++;
|
||||
expect([p.class, adversarialRan, dispatches.map(d => `${d.role}:${d.effort}`)]).toEqual(['gate', false, ['adversarial-native:full', 'outside-voice:host', 'targeted:full']]);
|
||||
[step, reason, dispatches] = ['stop', reason.slice(reason.indexOf('--adversarial-ran (') + '--adversarial-ran ('.length, -1), []];
|
||||
}
|
||||
rows.push({ name, fullPasses, last, bits, adversarialRan, class: p.class, step, reason, dispatches, skipped: p.skipped });
|
||||
}
|
||||
expect([rows.length, voices, sha(rows)]).toEqual([1440, 71, MAIN_PLANS]);
|
||||
expect(sha(sectionTexts(ROOT))).toBe(MAIN_SECTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanNumbers', () => {
|
||||
test('finds claims and fingerprints them', () => {
|
||||
const a = scanNumbers({ 'docs/bench.md': ['Median dropped 48% to 1.2s', 'no numbers here'] });
|
||||
|
||||
@@ -69,11 +69,14 @@ const NO_LOSS_REPLAYS = {
|
||||
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
|
||||
'GST-508+R51+GST-911': "wave review-53cfb5bfe011 merge of main 3d99c2249 (#3848) into #3852: lib/review-depth.ts keeps GST-508's review_exclude_paths (no-loss replay, GStack Minder drive handoffs/GSTA-202/noloss-results.md), R51 with GST-850's dispatch-superset no-loss check, and GST-911's SVG drawings with GST-911-redos (no-loss replay: 0 of 100 main commits changed, reports/GSTA-249-911-decisions.tsv; test/review-planner-misreads.test.ts, test/review-risk-redos.test.ts); lib/review-risk.ts is main's GST-911 bytes. Merged-head checks (handoffs/GSTA-202/merge-3d99c22-checks.md): Arm P on unit 3593 differs only in the exclude pathspec and the disclosure line, the same brief diff as before the merge, and GST-911 panel unit f5cb6ec1e plans, dispatches and renders byte-identical briefs under 3d99c2249 and the merged head",
|
||||
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
|
||||
'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged",
|
||||
'GST-508+GST-464+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md",
|
||||
'GST-508+R51+GST-911+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)",
|
||||
} as const;
|
||||
type Replay = keyof typeof NO_LOSS_REPLAYS;
|
||||
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
'ship/sections/review-army.md': { sha: '08656d4272c3c2ebf288a6a2c5e2126fa27f3d2bb9910bad925534e10e22a669', replay: 'GST-508+GST-464' },
|
||||
'ship/sections/adversarial.md': { sha: 'dacc8a01cd3ac8ffcf124334f55b0749d91b72d14eae059a0c8cfc8adca42b84', replay: 'GST-508+GST-464' },
|
||||
'ship/sections/review-army.md': { sha: 'de3e0d35f2af56492c42fd287b6868fbe19bce96b86321e74a4947ba909b74eb', replay: 'GST-508+GST-464+GST-967' },
|
||||
'ship/sections/adversarial.md': { sha: '0142d6bc3f2a5bceb9f8b2adef1d3a206e5836c99a0667fe995568da2e80a5a6', replay: 'GST-508+GST-464+GST-967' },
|
||||
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
|
||||
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
|
||||
'review/checklist.md': { sha: 'a32963ef38d9d3613c58c805e8517cd2e593c63769b30dd7d40e2592fa48fc78', replay: 'GSTA-224 H1' },
|
||||
@@ -90,7 +93,7 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
|
||||
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
|
||||
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
|
||||
'lib/review-depth.ts': { sha: '2b55401a42755b77db4f4003f23153e66504602885ab8419e17a195c750f5891', replay: 'GST-508+R51+GST-911' },
|
||||
'lib/review-depth.ts': { sha: 'd76a9afcfadfe0a95b15428c04e6f7a88c6b0fa75591201c45f023fec2bb90db', replay: 'GST-508+R51+GST-911+GST-967' },
|
||||
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-508+R51+GST-911' },
|
||||
};
|
||||
|
||||
|
||||
@@ -740,6 +740,26 @@ describe('Capy pass program (fake host, real bins)', () => {
|
||||
expect(h.creates.find(c => c.title === 'ship x21p2 red-team')!.prompt).toContain('DIFF_BASE=' + rangeBase);
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=the pass program dispatches the owner's granted fix-check from the plan, as decision 3.5 runs any fix-check, instead of PASS: none; fails_when=a recorded grant still prints PASS: none next=stop and the lane renders, sends and collects the fix-check by hand (C6-36 after-opus-grant);
|
||||
// why_new=GST-967; seam=the pass program; no_claim=the fix-check reviewer's verdict
|
||||
test('GST-967: a recorded grant after round 2 runs its one scoped fix-check through the program', async () => {
|
||||
const { dir, env } = repo();
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||
const add = spawnSync(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${head.slice(0, 12)})`, '--words', 'one scoped fix-check after round 2', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: dir, env: { ...env, GSTACK_APPROVALS_MIRROR: '0' }, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(add.status, add.stderr).toBe(0);
|
||||
const id = JSON.parse(add.stdout).added[0] as string;
|
||||
const files = path.join(dir, '.state/gfc');
|
||||
fs.mkdirSync(files, { recursive: true });
|
||||
const fc: Record<string, string> = {};
|
||||
for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n'], ['callers', 'none\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); }
|
||||
const h = host(dir, env);
|
||||
const res = await runPass(h, { base: 'main', out: path.join(dir, '.state/run/gfc'), pass: 'x30gfc', planFlags: ['--pass', '2', '--last', 'full', '--fixed-blocking', '--range-base', head, '--grant', id], fixCheck: fc, logStart: ['review-fix-check'] });
|
||||
expect(res).not.toContain('PASS: none');
|
||||
expect(res).toContain(`NEXT: fix-check (the owner's grant ${id}`);
|
||||
expect(h.creates.map(c => c.title)).toEqual(['ship x30gfc fix-check']);
|
||||
expect(res).toContain('NEXT: judge (Phase 4)');
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=the Capy pass program runs a scoped repeat with no fix-check files, dispatches its Red Team and leaves the fix-check to Step 3.5 as GST-619 does on the CLI; fails_when=the program stops a scoped plan for missing files or creates a fix-check reader in the scoped manifest;
|
||||
// why_new=#3670 Step 11 (adversarial 1, red team 1), re-scoped to GST-619's model in the merge of main; seam=none; no_claim=the Step 3.5 fix-check itself
|
||||
test('a scoped repeat runs without fixCheck files and leaves its fix-check to Step 3.5 (GST-619)', async () => {
|
||||
|
||||
@@ -85,6 +85,43 @@ describe('gstack-review-plan', () => {
|
||||
expect(r.stdout).not.toContain('NEXT: stop');
|
||||
expect(run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'fix-check', '--fixed-below', '--blocking-open']).stdout).toContain('NEXT: stop');
|
||||
});
|
||||
// Value: protects=a grant the owner recorded with gstack-approvals is something the plan can use, once, in the invocation it answers: --grant <id> plans the one scoped fix-check after a round-2 full pass when the row names review_loop_grant and the stop head (at or after --range-base, at or before HEAD); the fix-check never earns another; an unrecorded id, an unrelated approval, a review_loop_exception acceptance row, or a grant for an earlier head is refused; fails_when=the CLI ignores the grant and prints NEXT: stop (C6-36 after-opus-grant), accepts any of those rows, or grants a second fix-check;
|
||||
// why_new=GST-967 (round-2 red team: a grant from an earlier /ship on the branch could be reused); seam=bin/gstack-review-plan; no_claim=who may grant (the owner's words are the approval record's)
|
||||
test('GST-967: --grant <id> plans the one scoped fix-check for the stop head it names; any other row, or a grant for an earlier head, is refused', () => {
|
||||
const r = repo({ 'src/a.ts': 'export const a = 1;\n' });
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_STATE_ROOT: r.state, GSTACK_APPROVALS_MIRROR: '0' };
|
||||
delete env.GSTACK_APPROVALS_DIR;
|
||||
const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||
const commit = (n: number) => { fs.appendFileSync(path.join(r.dir, 'src/a.ts'), `export const v${n} = ${n};\n`); git('commit', '-qam', `c${n}`); return git('rev-parse', 'HEAD'); };
|
||||
const stale = git('rev-parse', 'HEAD');
|
||||
const passHead = commit(1);
|
||||
const stopHead = commit(2);
|
||||
const add = (ask: string) => {
|
||||
const a = spawnSync(path.join(path.dirname(BIN), 'gstack-approvals'), ['add', '--ask', ask, '--words', 'do it', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env });
|
||||
expect(a.status, a.stderr).toBe(0);
|
||||
return JSON.parse(a.stdout).added[0] as string;
|
||||
};
|
||||
const id = add(`Owner grant: one scoped fix-check after round 2 (review_loop_grant ${stopHead.slice(0, 12)})`);
|
||||
const refusedIds = [add(`Owner grant from an earlier run (review_loop_grant ${stale.slice(0, 12)})`), add('Owner grant: one scoped fix-check (review_loop_grant)'),
|
||||
add(`Owner accepts the stop after the fix-check (review_loop_exception ${stopHead.slice(0, 12)})`), add('Approve the plan for the README rewrite'), 'a-000000000000'];
|
||||
const plan = (a: string[]) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', ...a], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env });
|
||||
const capStop = ['--pass', '2', '--last', 'full', '--fixed-blocking', '--range-base', passHead];
|
||||
expect(plan(capStop).stdout).toContain('NEXT: stop');
|
||||
const granted = plan([...capStop, '--grant', id]);
|
||||
expect(granted.status, granted.stderr).toBe(0);
|
||||
expect(granted.stdout).toContain(`NEXT: fix-check (the owner's grant ${id}`);
|
||||
expect(granted.stdout).toContain('DISPATCH: fix-check fix-check effort=full');
|
||||
commit(3);
|
||||
expect(plan([...capStop, '--grant', id]).stdout).toContain(`NEXT: fix-check (the owner's grant ${id}`);
|
||||
expect(plan(['--pass', '2', '--last', 'fix-check', '--fixed-blocking', '--range-base', passHead, '--grant', id]).stdout).toContain('NEXT: stop');
|
||||
for (const other of refusedIds) {
|
||||
const refused = plan([...capStop, '--grant', other]);
|
||||
expect([other, refused.status, refused.stdout]).toEqual([other, 3, '']);
|
||||
expect(refused.stderr).toContain(`--grant ${other}`);
|
||||
}
|
||||
expect(plan([...capStop.slice(0, -2), '--grant', id]).status).toBe(2);
|
||||
expect(plan([...capStop, '--grant', '--json']).status).toBe(2);
|
||||
});
|
||||
test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => {
|
||||
const r = repo({ 'docs/howto.md': '# x\n' });
|
||||
const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; };
|
||||
|
||||
@@ -166,6 +166,83 @@ describe('review start/end binding (#2803)', () => {
|
||||
});
|
||||
}
|
||||
|
||||
for (const when of ['created', 'removed']) {
|
||||
test(`GST-985: an untracked scratch dir ${when} mid-review still binds; a tracked edit still breaks it`, () => {
|
||||
const scratchDir = join(repo, '.review-units-test-x');
|
||||
if (when === 'removed') { mkdirSync(scratchDir); writeFileSync(join(scratchDir, 'unit.json'), '{}\n'); }
|
||||
const token = cli('gstack-review-log', ['--start', 'review']);
|
||||
if (when === 'created') { mkdirSync(scratchDir); writeFileSync(join(scratchDir, 'unit.json'), '{}\n'); }
|
||||
else rmSync(scratchDir, { recursive: true, force: true });
|
||||
const row = log(token);
|
||||
expect(row.review_binding.state).toBe('verified');
|
||||
expect(row.review_binding.start_wtree).toBe(row.review_binding.end_wtree);
|
||||
expect(row.review_freshness.status).toBe('CURRENT');
|
||||
expect(git('diff', '--name-only', row.wtree, cli('gstack-wtree'))).toBe(when === 'created' ? '.review-units-test-x/unit.json' : '');
|
||||
|
||||
const edited = cli('gstack-review-log', ['--start', 'review']);
|
||||
writeFileSync(join(repo, 'source.ts'), 'export const unreviewed = true;\n');
|
||||
expect(log(edited).review_binding.state).toBe('changed');
|
||||
});
|
||||
}
|
||||
|
||||
test('GST-985: ignored root dot-directories still fingerprint under --review', () => {
|
||||
writeFileSync(join(repo, '.git', 'info', 'exclude'), '.cache/\n');
|
||||
mkdirSync(join(repo, '.cache'));
|
||||
writeFileSync(join(repo, '.cache', 'blob'), 'x\n');
|
||||
expect(cli('gstack-wtree', ['--review'])).toBe(cli('gstack-wtree'));
|
||||
});
|
||||
|
||||
test('GST-985: an untracked file in a dot-directory that holds a tracked file still breaks the bind', () => {
|
||||
mkdirSync(join(repo, '.tool'));
|
||||
writeFileSync(join(repo, '.tool', 'a.json'), '{}\n');
|
||||
git('add', '.tool/a.json');
|
||||
git('commit', '-qm', 'tool');
|
||||
const token = cli('gstack-review-log', ['--start', 'review']);
|
||||
writeFileSync(join(repo, '.tool', 'b.json'), '{}\n');
|
||||
expect(log(token).review_binding.state).toBe('changed');
|
||||
});
|
||||
|
||||
test('GST-985: a root ..x scratch dir created mid-review still binds', () => {
|
||||
const token = cli('gstack-review-log', ['--start', 'review']);
|
||||
mkdirSync(join(repo, '..x'));
|
||||
writeFileSync(join(repo, '..x', 'unit.json'), '{}\n');
|
||||
expect(log(token).review_binding.state).toBe('verified');
|
||||
});
|
||||
|
||||
test('GST-985: a git add -N file in a new dot-directory counts under the read-tree HEAD fallback', () => {
|
||||
mkdirSync(join(repo, '.newdir'));
|
||||
writeFileSync(join(repo, '.newdir', 'a.ts'), 'export const a = 1;\n');
|
||||
git('add', '-N', '.newdir/a.ts');
|
||||
const fakeBin = mkdtempSync(join(tmpdir(), 'review-start-faketouch-'));
|
||||
writeFileSync(join(fakeBin, 'touch'), '#!/bin/sh\nexit 1\n');
|
||||
chmodSync(join(fakeBin, 'touch'), 0o755);
|
||||
try {
|
||||
const env = { PATH: `${fakeBin}:${process.env.PATH}` };
|
||||
expect(cli('gstack-wtree', ['--review'], repo, env)).toBe(cli('gstack-wtree', [], repo, env));
|
||||
} finally {
|
||||
rmSync(fakeBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('GST-985: gstack-wtree refuses an unknown or extra argument', () => {
|
||||
for (const args of [['--bogus'], ['--review', 'x']]) {
|
||||
const r = spawnSync(join(ROOT, 'bin', 'gstack-wtree'), args, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('Usage: gstack-wtree [--review]');
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of ['.github/workflows/x.yml', '.gstack/ship-policy.json']) {
|
||||
test(`GST-985: an untracked ${file} added mid-review still breaks the bind`, () => {
|
||||
const token = cli('gstack-review-log', ['--start', 'review']);
|
||||
mkdirSync(join(repo, file, '..'), { recursive: true });
|
||||
writeFileSync(join(repo, file), '{}\n');
|
||||
const row = log(token);
|
||||
expect(row.review_binding.state).toBe('changed');
|
||||
expect(git('diff', '--name-only', row.review_binding.start_wtree, row.review_binding.end_wtree)).toBe(file);
|
||||
});
|
||||
}
|
||||
|
||||
test('fix commits do not certify the final tree until a new unchanged pass', () => {
|
||||
const token = cli('gstack-review-log', ['--start', 'review']);
|
||||
writeFileSync(join(repo, 'source.ts'), 'export const fixed = true;\n');
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Every Agent SDK caller hands Claude Code its real system prompt, or names
|
||||
* why not. Without systemPrompt the SDK runs a bare stub that drops the "run
|
||||
* /skill via Skill" line, so a skill arm never learns to load its skill and a
|
||||
* null result means nothing (docs/TESTING_INTERNALS.md#agent-sdk-system-prompt).
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLAUDE_CODE_PRESET, sdkSystemPrompt } from './helpers/sdk-system-prompt';
|
||||
import { runAgentSdkTest } from './helpers/agent-sdk-runner';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SELF = 'test/sdk-system-prompt-tripwire.test.ts';
|
||||
const DEFINERS = new Set(['test/helpers/agent-sdk-runner.ts', 'test/helpers/sdk-system-prompt.ts']);
|
||||
|
||||
/** Index just past the bracket that closes the one opened at text[open]. */
|
||||
function closeOf(text: string, open: number): number {
|
||||
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' };
|
||||
const stack: string[] = [];
|
||||
for (let i = open; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (c === '/' && text[i + 1] === '/') { i = text.indexOf('\n', i); if (i < 0) return text.length; continue; }
|
||||
if (c === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i + 2) + 1; continue; }
|
||||
if (c === "'" || c === '"' || c === '\x60') {
|
||||
for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++;
|
||||
continue;
|
||||
}
|
||||
if (pairs[c]) stack.push(pairs[c]!);
|
||||
else if (c === stack[stack.length - 1]) { stack.pop(); if (!stack.length) return i + 1; }
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/** Text of one property value: from after "key:" to the next top-level , } or ). */
|
||||
function valueAt(text: string, start: number): string {
|
||||
let i = start;
|
||||
while (i < text.length && !',})'.includes(text[i]!)) {
|
||||
const c = text[i]!;
|
||||
if ('({['.includes(c)) { i = closeOf(text, i); continue; }
|
||||
if (c === "'" || c === '"' || c === '\x60') { for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++; }
|
||||
i++;
|
||||
}
|
||||
return text.slice(start, i).trim();
|
||||
}
|
||||
|
||||
/** Why a systemPrompt value is refused, or null when it is the preset, names its reason, or forwards a caller's value. */
|
||||
export function promptValueProblem(value: string): string | null {
|
||||
if (/^['"\x60]/.test(value)) return 'a bare string prompt (empty runs the SDK stub); pass CLAUDE_CODE_PRESET or name a reason';
|
||||
if (/^[\w$.?]+$/.test(value)) return null;
|
||||
const objects = [...value.matchAll(/\{[^{}]*\}/g)].map(m => m[0]);
|
||||
if (!objects.length) return null;
|
||||
for (const o of objects) {
|
||||
if (/type:\s*'preset'/.test(o) && /preset:\s*'claude_code'/.test(o)) continue;
|
||||
if (/type:\s*'(sdk-default|custom)'/.test(o) && /reason:\s*(['"\x60])\s*[^'"\x60\s][^'"\x60]*\1/.test(o)) continue;
|
||||
return 'a prompt that is neither the Claude Code preset nor a named sdk-default/custom reason';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every refused systemPrompt and every SDK call that omits one, in one file's source. */
|
||||
export function scanSource(file: string, source: string): string[] {
|
||||
const problems: string[] = [];
|
||||
const line = (i: number) => source.slice(0, i).split('\n').length;
|
||||
if (!DEFINERS.has(file)) {
|
||||
for (const m of source.matchAll(/\bsystemPrompt\s*:/g)) {
|
||||
const problem = promptValueProblem(valueAt(source, m.index! + m[0].length));
|
||||
if (problem) problems.push(file + ':' + line(m.index!) + ': ' + problem);
|
||||
}
|
||||
}
|
||||
const sdk = /import\s*\{[^}]*\bquery\b[^}]*\}\s*from\s*'@anthropic-ai\/claude-agent-sdk'|\(await import\('@anthropic-ai\/claude-agent-sdk'\)\)\.query|const\s*\{\s*query\s*\}\s*=\s*await import\('@anthropic-ai\/claude-agent-sdk'\)/.test(source);
|
||||
const calls = [...source.matchAll(/(?<![\w.])runAgentSdkTest\(/g)].map(m => m.index! + m[0].length - 1);
|
||||
if (sdk && !DEFINERS.has(file)) calls.push(...[...source.matchAll(/(?<![\w.])query\(/g)].map(m => m.index! + m[0].length - 1));
|
||||
for (const open of calls) {
|
||||
if (/function\s+runAgentSdkTest\($/.test(source.slice(Math.max(0, open - 40), open + 1))) continue;
|
||||
const arg = source.slice(open + 1, closeOf(source, open) - 1).trim();
|
||||
if (!arg.startsWith('{')) continue;
|
||||
if (/\bsystemPrompt\b|\.\.\.\s*sdkSystemPrompt\(/.test(arg)) continue;
|
||||
if (/\.\.\./.test(arg)) continue;
|
||||
problems.push(file + ':' + line(open) + ': SDK call without systemPrompt; pass CLAUDE_CODE_PRESET or name a reason');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function trackedSources(): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = path.posix.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { if (entry.name !== 'node_modules' && rel !== 'test/fixtures') walk(rel); }
|
||||
else if (/\.tsx?$/.test(entry.name) && rel !== SELF) out.push(rel);
|
||||
}
|
||||
};
|
||||
walk('test'); walk('scripts');
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Agent SDK system prompt tripwire', () => {
|
||||
test('every runAgentSdkTest and SDK query caller passes the preset or names its reason', () => {
|
||||
const files = trackedSources();
|
||||
const problems = files.flatMap(file => scanSource(file, fs.readFileSync(path.join(ROOT, file), 'utf8')));
|
||||
expect(problems).toEqual([]);
|
||||
const callers = files.filter(file => /(?<![\w.])runAgentSdkTest\(/.test(fs.readFileSync(path.join(ROOT, file), 'utf8')));
|
||||
expect(callers).toContain('test/helpers/ship-hook-actor.ts');
|
||||
expect(callers.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('the scanner refuses a missing, empty, bare or unnamed prompt and accepts the preset, a named reason or a forward', () => {
|
||||
const scan = (body: string) => scanSource('x.ts', body);
|
||||
expect(scan("runAgentSdkTest({ userPrompt: 'go', workingDirectory: d })")).toHaveLength(1);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: '', userPrompt: 'go' })")).toHaveLength(1);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: 'be terse', userPrompt: 'go' })")).toHaveLength(1);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: { type: 'sdk-default', reason: '' }, userPrompt: 'go' })")).toHaveLength(1);
|
||||
expect(scan("import { query } from '@anthropic-ai/claude-agent-sdk';\nquery({ prompt: 'p', options: { tools: [] } })")).toHaveLength(1);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: { type: 'preset', preset: 'claude_code' }, userPrompt: 'go' })")).toEqual([]);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: a ? { type: 'preset', preset: 'claude_code', append: x } : { type: 'preset', preset: 'claude_code' } })")).toEqual([]);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: { type: 'sdk-default', reason: 'stub provider' }, userPrompt: 'go' })")).toEqual([]);
|
||||
expect(scan("runAgentSdkTest({ systemPrompt: CLAUDE_CODE_PRESET, userPrompt: 'go' })")).toEqual([]);
|
||||
expect(scan("runAgentSdkTest({ ...options, signal })")).toEqual([]);
|
||||
expect(scan("const o = { systemPrompt: options.systemPrompt };")).toEqual([]);
|
||||
expect(scan("import { query } from '@anthropic-ai/claude-agent-sdk';\nquery({ prompt: 'p', options: { ...sdkSystemPrompt({ type: 'sdk-default', reason: 'probe' }) } })")).toEqual([]);
|
||||
expect(scan("import { query } from '@anthropic-ai/claude-agent-sdk';\nquery({ ...args, options: { ...args.options, cwd } })")).toEqual([]);
|
||||
});
|
||||
|
||||
test('the runner refuses a bare or unnamed prompt before it starts', async () => {
|
||||
const queryProvider = (() => { throw new Error('query must not run'); }) as never;
|
||||
for (const systemPrompt of ['', 'be terse', undefined, { type: 'sdk-default', reason: ' ' }, { type: 'custom', reason: 'x' }]) {
|
||||
await expect(runAgentSdkTest({ systemPrompt: systemPrompt as never, userPrompt: 'go', workingDirectory: ROOT, queryProvider, maxRetries: 0 }))
|
||||
.rejects.toThrow('docs/TESTING_INTERNALS.md#agent-sdk-system-prompt');
|
||||
}
|
||||
expect(sdkSystemPrompt(CLAUDE_CODE_PRESET)).toEqual({ systemPrompt: CLAUDE_CODE_PRESET });
|
||||
expect(sdkSystemPrompt({ type: 'sdk-default', reason: 'stub provider' })).toEqual({});
|
||||
expect(sdkSystemPrompt({ type: 'custom', text: 'be terse', reason: 'overlay-only arm' })).toEqual({ systemPrompt: 'be terse' });
|
||||
});
|
||||
});
|
||||
@@ -170,7 +170,7 @@ describe('setup-gbrain owned Path 4 fixture', () => {
|
||||
let failure = '';
|
||||
try {
|
||||
await runSetupGbrainAttempt(fixture, {
|
||||
systemPrompt: '', userPrompt: 'free deadline probe', queryProvider,
|
||||
systemPrompt: { type: 'sdk-default', reason: 'stub queryProvider: no model reads the prompt' }, userPrompt: 'free deadline probe', queryProvider,
|
||||
pathToClaudeCodeExecutable: '/nonexistent/free-test-never-spawn-claude',
|
||||
signal: controller.signal,
|
||||
}, () => { validated = true; }, mode === 'deadline' ? 250 : 1000, {
|
||||
@@ -213,7 +213,7 @@ describe('setup-gbrain owned Path 4 fixture', () => {
|
||||
})() as ReturnType<QueryProvider>;
|
||||
try {
|
||||
await expect(runSetupGbrainAttempt(fixture, {
|
||||
systemPrompt: '', userPrompt: 'free validation cancellation probe', queryProvider,
|
||||
systemPrompt: { type: 'sdk-default', reason: 'stub queryProvider: no model reads the prompt' }, userPrompt: 'free validation cancellation probe', queryProvider,
|
||||
pathToClaudeCodeExecutable: '/nonexistent/free-test-never-spawn-claude', signal: controller.signal,
|
||||
}, async () => {
|
||||
await Promise.resolve();
|
||||
@@ -276,7 +276,7 @@ describe('setup-gbrain owned Path 4 fixture', () => {
|
||||
let thrown = '';
|
||||
try {
|
||||
await runSetupGbrainAttempt(fixture, {
|
||||
systemPrompt: '', userPrompt: 'free fixture probe', queryProvider,
|
||||
systemPrompt: { type: 'sdk-default', reason: 'stub queryProvider: no model reads the prompt' }, userPrompt: 'free fixture probe', queryProvider,
|
||||
canUseTool: async (_tool, input) => ({ behavior: 'allow', updatedInput: {
|
||||
...input, answers: { 'Want symbol-aware code search?': 'Yes, local PGLite' },
|
||||
} }),
|
||||
|
||||
@@ -24,6 +24,37 @@ const nonceOf = (out: string) => /^WAVE: (\S+)/m.exec(out)?.[1] ?? '';
|
||||
const waveJson = (fx: TrainFixture, nonce: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'waves', `${nonce}.json`), 'utf8'));
|
||||
const gitIn = (fx: TrainFixture, cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } });
|
||||
|
||||
/** Commit seed files onto main and push them. */
|
||||
function seedMain(fx: TrainFixture, files: Record<string, string>, message = 'seed'): void {
|
||||
fx.repo.git('checkout', '-q', 'origin/main', '-B', 'seed');
|
||||
for (const [f, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(fx.repo.work, f), t); }
|
||||
fx.repo.git('add', '-A');
|
||||
fx.repo.git('commit', '-q', '-m', message);
|
||||
fx.repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
fx.repo.git('fetch', '-q', 'origin');
|
||||
}
|
||||
|
||||
/** A READY PR off main (or, orphan, with no history in common with main) writing (or, for null, deleting) files, with green CI and a READY receipt at its head. */
|
||||
function readyPr(fx: TrainFixture, n: string, files: Record<string, string | null>, orphan = false): string {
|
||||
if (orphan) { fx.repo.git('checkout', '-q', '--orphan', `pr-${n}`); fx.repo.git('rm', '-rqf', '.'); }
|
||||
else fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`);
|
||||
for (const [f, t] of Object.entries(files)) {
|
||||
if (t === null) { fs.rmSync(path.join(fx.repo.work, f)); continue; }
|
||||
fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true });
|
||||
fs.writeFileSync(path.join(fx.repo.work, f), t);
|
||||
}
|
||||
fx.repo.git('add', '-A');
|
||||
fx.repo.git('commit', '-q', '-m', `feat #${n}`);
|
||||
const head = fx.repo.git('rev-parse', 'HEAD');
|
||||
fx.repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${n}/head`);
|
||||
fx.write(d => {
|
||||
d.prs[n] = { headRefOid: head, isCrossRepository: false, state: 'OPEN', headRefName: `pr-${n}`, url: `https://github.com/fixture/repo/pull/${n}`, body: shippedBody(n, head) };
|
||||
d.ci[head] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }];
|
||||
});
|
||||
readyReceipt(fx, n, head);
|
||||
return head;
|
||||
}
|
||||
|
||||
function threePrs(fx: TrainFixture) {
|
||||
return ['1', '2', '3'].map(pr => openPr(fx, pr, { file: `lib/ship-${pr}.ts`, changelog: pr === '1' }));
|
||||
}
|
||||
@@ -79,6 +110,15 @@ describe('areas and grouping', () => {
|
||||
for (const hub of ['docs/troubleshooting.md', 'ship/SKILL.md', 'scripts/harness-version.json']) expect(linked([hub], () => [hub])).toEqual([]);
|
||||
expect([isHubFile('docs/x.ts'), isHubFile('TODOS.md'), isHubFile('test/fixtures/x.json'), isHubFile('ship/SKILL.md.tmpl'), isHubFile('lib/x.ts')]).toEqual([true, true, true, false, false]);
|
||||
});
|
||||
|
||||
test('groupWaves never splits a conflict cluster across waves, and a cluster over the wave maximum is oversize and stays whole (GST-770 follow-up)', () => {
|
||||
const pr = (n: number, file: string): PlanPr => ({ pr: String(n), head: String(n).repeat(40).slice(0, 40), files: [file], lines: 10 });
|
||||
const cluster = (from: number, to: number, file: string) => Array.from({ length: to - from + 1 }, (_, i) => pr(from + i, file));
|
||||
const plan = groupWaves([...cluster(1, 4, 'lib/ship-x.ts'), ...cluster(5, 8, 'lib/ship-y.ts'), ...cluster(9, 15, 'lib/ship-z.ts')], f => areaKey(f, () => false), 3, 6);
|
||||
expect(plan.groups.map(g => g.prs.map(p => p.pr))).toEqual([['1', '2', '3', '4'], ['5', '6', '7', '8']]);
|
||||
expect(plan.oversize.map(g => [g.area, g.prs.map(p => p.pr)])).toEqual([['ship', ['9', '10', '11', '12', '13', '14', '15']]]);
|
||||
expect(plan.solo).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wave build', () => {
|
||||
@@ -309,6 +349,45 @@ describe('ready and train build read the wave', () => {
|
||||
expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]);
|
||||
expect(unread.err).toContain('#4 (CI unknown at');
|
||||
}, 180_000);
|
||||
|
||||
test('wave plan leaves out a READY head that conflicts with main as NOT_MERGEABLE, and keeps one whose only conflict is the CHANGELOG the train merges (GST-770 follow-up)', () => {
|
||||
const fx = make();
|
||||
seedMain(fx, { 'lib/ship-a.ts': 'export const a = 0;\n' });
|
||||
const changelog = (entry: string) => fx.repo.git('show', 'origin/main:CHANGELOG.md').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- ${entry}\n`) + '\n';
|
||||
const conflicting = readyPr(fx, '1', { 'lib/ship-a.ts': "export const a = 'one';\n" });
|
||||
readyPr(fx, '2', { 'lib/ship-b.ts': 'export const b = 2;\n', 'CHANGELOG.md': changelog('#2 change') });
|
||||
seedMain(fx, { 'lib/ship-a.ts': "export const a = 'main';\n", 'CHANGELOG.md': changelog('a change that landed first') }, 'main moves');
|
||||
const plan = queue(fx, ['wave', 'plan']);
|
||||
expect([plan.status, plan.err]).toEqual([EXIT.ok, '']);
|
||||
expect(plan.out.split('\n').filter(l => l.startsWith('NOT_MERGEABLE:'))).toEqual([
|
||||
`NOT_MERGEABLE: #1 (conflicts with origin/main at ${conflicting.slice(0, 12)} in lib/ship-a.ts; left out until the head merges cleanly onto main)`,
|
||||
]);
|
||||
expect(line(plan.out, 'QUEUE:')).toContain('1 live READY PR(s)');
|
||||
readyPr(fx, '3', { 'lib/ship-c.ts': 'export const c = 3;\n' }, true);
|
||||
const unread = queue(fx, ['wave', 'plan']);
|
||||
expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]);
|
||||
expect(unread.err).toContain('#3 (merge onto main unreadable: (git merge-tree failed');
|
||||
}, 180_000);
|
||||
|
||||
test('wave plan links a rename with an edit of the old path that conflicts with it, not with an edit that merges cleanly, and prints a cluster over --max as WAVE_OVERSIZE with no build command (GST-770 follow-up)', () => {
|
||||
const fx = make();
|
||||
const body = (first = '', last = '') => Array.from({ length: 10 }, (_, i) => i === 0 && first ? `export const o0 = '${first}';\n` : i === 9 && last ? `export const o9 = '${last}';\n` : `export const o${i} = ${i};\n`).join('');
|
||||
seedMain(fx, { 'lib/ship-old.ts': body() });
|
||||
readyPr(fx, '1', { 'lib/ship-old.ts': null, 'lib/ship-new.ts': body('one') });
|
||||
readyPr(fx, '2', { 'lib/ship-old.ts': body('two'), 'lib/review-2.ts': 'export const r = 2;\n', 'test/review-2.test.ts': '// #2\n' });
|
||||
readyPr(fx, '3', { 'lib/ship-old.ts': body('', 'three'), 'lib/pregate-3.ts': 'export const p = 3;\n', 'test/pregate-3.test.ts': '// #3\n' });
|
||||
const plan = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '2']);
|
||||
expect([plan.status, plan.err]).toEqual([EXIT.ok, '']);
|
||||
expect(plan.out.split('\n').filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=ship prs=#1,#2 integrator=#1 (most changed lines)']);
|
||||
expect(line(plan.out, 'SOLO:')).toBe('SOLO: #3 (no area group of 2+; they ride as single slots)');
|
||||
const small = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '1', '--max', '1']);
|
||||
expect([small.status, small.err]).toEqual([EXIT.ok, '']);
|
||||
const rows = small.out.split('\n');
|
||||
const at = rows.findIndex(l => l.startsWith('WAVE_OVERSIZE:'));
|
||||
expect(rows[at]).toBe('WAVE_OVERSIZE: area=ship prs=#1,#2 (2 PRs whose changes conflict, over the wave maximum 1; kept whole with no build command: land part of it as single slots, then run wave plan again)');
|
||||
expect(rows[at + 1]).not.toContain('build:');
|
||||
expect(rows.filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=pregate prs=#3 integrator=#3 (most changed lines)']);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('wave settle', () => {
|
||||
|
||||
@@ -118,6 +118,30 @@ describe('the rendered Approvals block is not prose', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Review-loop exception line (GST-977, gstack-evals #139)', () => {
|
||||
const excepted = {
|
||||
...receipt,
|
||||
review_loop: { ...receipt.review_loop, fix_checks: 1 },
|
||||
gate: { status: 'green', run: '139', unit: { pass: 1181, skip: 70 } },
|
||||
review_loop_exception: { accepted_by: 'Garry', date: '2026-11-01', via: 'GEVA-2', reason: 'outside 1 and 2 skip detector', fix_check: { head: 'b'.repeat(40), findings: 0 } },
|
||||
};
|
||||
const line = 'Review-loop exception: accepted by Garry 2026-11-01 via GEVA-2: outside 1 and 2 skip detector (fix-check at bbbbbbbbbbbb, 0 findings)';
|
||||
|
||||
// Value: protects=a body that carries the exception line write requires can also pass --check-prose; fails_when=the
|
||||
// owner's reason is read as counter claims ("2 skip" against 70); seam=lib/ship-receipt-prose.ts checkReceiptProse
|
||||
test('the exact line the receipt\'s exception requires is not prose; the rest of the body still is', () => {
|
||||
expect(checkReceiptProse(excepted, `${line}\n\nGate: 70 skip.\n`)).toEqual({ checked: 1, mismatches: [] });
|
||||
expect(checkReceiptProse(excepted, ` ${line}\r\nGate: 69 skip.`).mismatches).toEqual([{ field: 'gate.unit.skip', prose: 69, receipt: 70 }]);
|
||||
});
|
||||
|
||||
test('the same words anywhere else, or a line for a different exception, stay prose', () => {
|
||||
const twoSkip = [{ field: 'gate.unit.skip', prose: 2, receipt: 70 }];
|
||||
expect(checkReceiptProse(excepted, `Note: ${line}`).mismatches).toEqual(twoSkip);
|
||||
expect(checkReceiptProse(excepted, line.replace('GEVA-2', 'GEVA-3')).mismatches).toEqual(twoSkip);
|
||||
expect(checkReceiptProse({ ...excepted, review_loop_exception: undefined }, line).mismatches).toEqual(twoSkip);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt read --check-prose', () => {
|
||||
const run = (body: string) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 476,
|
||||
"why": "GST-967: Step 11 voices before any gate-class stop (NEXT: voices) and the owner's recorded grant (--grant) in the repeat decision and Step 11"
|
||||
}
|
||||
@@ -163,4 +163,42 @@ describe('test preload hermeticity', () => {
|
||||
expect(r.stderr).toContain('test-setup: stripped inherited GSTACK_STATE_DIR; tests isolate state via GSTACK_HOME');
|
||||
expect(r.stderr.match(/test-setup: stripped/g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
test('bun test strips an inherited live-brain URL, so a temp-HOME gbrain child never sees it (#3466)', () => {
|
||||
const live = path.join(tmp, 'live-gbrain');
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
fs.writeFileSync(path.join(live, 'config.json'), JSON.stringify({ database_url: 'postgresql://live-config.invalid/brain' }));
|
||||
const childHome = path.join(tmp, 'child-home');
|
||||
fs.mkdirSync(childHome, { recursive: true });
|
||||
const probe = path.join(tmp, 'brain-probe.test.ts');
|
||||
fs.writeFileSync(probe, [
|
||||
"import { test, expect } from 'bun:test';",
|
||||
"import { spawnSync } from 'child_process';",
|
||||
"test('probe', () => {",
|
||||
" const script = \"const { buildGbrainEnv } = require(\" + JSON.stringify(" + JSON.stringify(path.join(ROOT, 'lib', 'gbrain-exec.ts')) + ") + \"); const e = buildGbrainEnv(); console.log(['GBRAIN_DATABASE_URL','GBRAIN_DIRECT_DATABASE_URL','DATABASE_URL','GBRAIN_HOME'].map(k => k + '=' + (e[k] ?? '<unset>')).join(' '));\";",
|
||||
" const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', '-e', script], {",
|
||||
" env: { ...process.env, HOME: " + JSON.stringify(childHome) + " }, encoding: 'utf-8', timeout: 30_000,",
|
||||
" });",
|
||||
" expect(r.stdout.trim()).toBe('GBRAIN_DATABASE_URL=<unset> GBRAIN_DIRECT_DATABASE_URL=<unset> DATABASE_URL=<unset> GBRAIN_HOME=<unset>');",
|
||||
"});",
|
||||
].join('\n'));
|
||||
const r = spawnSync('bun', ['test', '--preload', path.join(ROOT, 'test-setup.ts'), probe], {
|
||||
cwd: tmp,
|
||||
env: {
|
||||
...env, GSTACK_HOME: resolved,
|
||||
GBRAIN_DATABASE_URL: 'postgresql://live-brain.invalid/brain',
|
||||
GBRAIN_DIRECT_DATABASE_URL: 'postgresql://live-brain.invalid/direct',
|
||||
DATABASE_URL: 'postgresql://live-brain.invalid/fallback',
|
||||
GBRAIN_HOME: live,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(r.stderr).not.toContain('live-brain.invalid');
|
||||
expect(r.stderr).not.toContain('live-config.invalid');
|
||||
expect(r.status).toBe(0);
|
||||
for (const name of ['GBRAIN_DATABASE_URL', 'GBRAIN_DIRECT_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_HOME']) {
|
||||
expect(r.stderr).toContain(`test-setup: stripped inherited ${name}; tests must not reach a live gbrain`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
loadFreeTestDurations,
|
||||
packShardsByDuration,
|
||||
wallTimeoutForPackedShard,
|
||||
ciShardJobTimeoutMinutes,
|
||||
ciPlanMatrix,
|
||||
MAX_CI_JOB_TIMEOUT_MINUTES,
|
||||
createFreeCiPlan,
|
||||
validateFreeCiPlan,
|
||||
verifyFreeCiResults,
|
||||
@@ -1101,4 +1104,30 @@ describe('test-free-shards: duration-aware packing (full-suite LPT)', () => {
|
||||
// When the prediction is the larger bound, it still wins.
|
||||
expect(wallTimeoutForPackedShard(600_000, WALL_BASE_MS, 10)).toBe(1_800_000);
|
||||
});
|
||||
|
||||
test('a shard\'s job timeout is its runner wall plus the flaky-retry wall plus setup, rounded up (#3850)', () => {
|
||||
// 750 s predicted -> 2250 s wall, + 360 s retry, + 300 s setup = 2910 s -> 49 min.
|
||||
expect(ciShardJobTimeoutMinutes({ shard: 1, files: ['a'], predictedMs: 750_000 }, 300)).toBe(49);
|
||||
// A tiny shard still covers the 6-minute base wall and the retry pass.
|
||||
expect(ciShardJobTimeoutMinutes({ shard: 2, files: ['a'], predictedMs: 1_000 }, 60)).toBe(13);
|
||||
});
|
||||
|
||||
test('a shard that would need a job over the cap fails the plan instead of being truncated', () => {
|
||||
const predictedMs = MAX_CI_JOB_TIMEOUT_MINUTES * 60_000 / 3;
|
||||
expect(() => ciShardJobTimeoutMinutes({ shard: 4, files: ['a'], predictedMs }, 300)).toThrow(/FREE_JOB_TIMEOUT_OVER_CAP: CI shard 4 needs a 101-minute job/);
|
||||
});
|
||||
|
||||
test('the matrix carries per-shard timeouts only when the plan is given a setup margin', () => {
|
||||
const plan = createFreeCiPlan(['a.test.ts', 'b.test.ts'], 2, { 'a.test.ts': 750_000, 'b.test.ts': 1_000 }, 'rev');
|
||||
expect(ciPlanMatrix(plan)).toEqual({ shard: [1, 2] });
|
||||
expect(ciPlanMatrix(plan, 300)).toEqual({ include: [{ shard: 1, timeout_minutes: 49 }, { shard: 2, timeout_minutes: 17 }] });
|
||||
});
|
||||
|
||||
test('--job-setup-seconds is a --ci-plan flag with whole seconds from 1 to 3600', () => {
|
||||
expect(parseCliOptions(['--ci-plan', 'p.json', '--job-setup-seconds', '300']).jobSetupSeconds).toBe(300);
|
||||
expect(() => parseCliOptions(['--shards', '2', '--job-setup-seconds', '300'])).toThrow('--job-setup-seconds applies only to --ci-plan');
|
||||
for (const bad of ['0', '3601', '1.5', 'x']) {
|
||||
expect(() => parseCliOptions(['--ci-plan', 'p.json', '--job-setup-seconds', bad])).toThrow('--job-setup-seconds needs a whole number of seconds from 1 to 3600');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -209,6 +209,7 @@ const BASELINE = [
|
||||
'test/review-enum-lifecycle.test.ts',
|
||||
'test/review-n-plus-one-contract.test.ts',
|
||||
'test/review-quality-provenance.test.ts', // landed in v1.91.7.0 before this ratchet: harness owner for its functional-QA/docsync paid evals
|
||||
'test/sdk-system-prompt-tripwire.test.ts', // the Agent SDK prompt tripwire: its subject is the eval harness (test/helpers/sdk-system-prompt.ts, agent-sdk-runner.ts) and every SDK caller under test/
|
||||
'test/session-runner-browse-errors.test.ts', // slices test/skill-e2e-review.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing)
|
||||
'test/session-runner-stream-lifecycle.test.ts',
|
||||
'test/session-runner-timeout.test.ts',
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('one fixture per runner writes the ledger the classifier reads', () =>
|
||||
setTimeout(() => args.options.abortController.abort(), 400);
|
||||
return Object.assign(iterator, { close: () => {} });
|
||||
}) as unknown as QueryProvider;
|
||||
const run = (events: any[]) => withEvalDir(() => runAgentSdkTest({ systemPrompt: '', userPrompt: 'x', workingDirectory: os.tmpdir(), maxRetries: 0,
|
||||
const run = (events: any[]) => withEvalDir(() => runAgentSdkTest({ systemPrompt: { type: 'sdk-default', reason: 'stub queryProvider: no model reads the prompt' }, userPrompt: 'x', workingDirectory: os.tmpdir(), maxRetries: 0,
|
||||
testName: 'sdk-fixture', sessionBudgetMs: 400, streamLiveness: true, queryProvider: hangingQuery(events) }).catch(error => error));
|
||||
const quiet = await run([{ type: 'system', subtype: 'init' }]);
|
||||
expect(quiet.rows).toEqual([expect.objectContaining({ key: 'agent-sdk:sdk-fixture#1', runner: 'agent-sdk', budget_ms: 400, end: 'session_timeout',
|
||||
|
||||
Reference in new issue
Block a user