v1.93.11.0 stamp: merge main daf661374f into #3872 (land)

This commit is contained in:
garrytan committed 2026-11-02 17:44:22 +00:00
commit 964f0cc2e9
184 files changed
+3881 -516

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1",
"runner": "bun test {files}",
"runner": "bun test --timeout=30000 {files}",
"remote_gate": "bash scripts/ubicloud/pregate-remote.sh",
"dependencies": {
".github/workflows/**": ["test/pregate-selection.test.ts", "test/free-tests-workflow-wiring.test.ts"],
+147
View File
@@ -1,5 +1,152 @@
# Changelog
## [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.
### Fixed
- **`lib/restamp.ts`:** a stamp_patterns match on a branch-added line that names the fork point's version is rewritten to the new version, like a mention of a newer tentative version. Lines the branch did not add, and versions below the fork point, are left alone.
- **`lib/ship-bin-common.ts`:** finish routes such a line as metadata (a stamp file), not behavior.
- **One stamp bound for the writer and the stamp checks** (`lib/stamp-only.ts` `stampOnlyLine` with the branch's side): the tree receipt behind `gstack-ship-queue stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (read against the slot's predecessor), and finish's route now accept a moved version on a line the branch added at or above the fork-point version. Before, the tree receipt and the carry still floored every line at the gated head's VERSION, so the stamp commit the writer now makes would have read `TREE: changed` and stopped the train with `QUEUE_STAMP_NOT_STAMP_ONLY`. The same move on a base line is still refused, unless that base line's text duplicates a line the branch added (GST-979). The tree receipt and the train checks also accept the move when the branch's own VERSION already equals the new version, as it does in a train slot that kept its claim. Two gaps remain, tracked in GST-980: the /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior.
### Tests
- `test/restamp-mentions.test.ts`: a branch-added row at the fork version after the base moved is restamped and its file is routed as metadata. On main, the stage reports `skipped` and ROUTE counts the file as behavior.
- `test/ship-rereview-triggers.test.ts`: the GST-895 fork-version case now routes as metadata.
- `test/restamp-mentions.test.ts`: `gstack-tree-receipt` reads the GST-963 stamp commit as `same-modulo-stamps` (it read `TREE: changed` before the shared bound), refuses the same move on a base row, and still accepts the GST-895 lost-slot stamp. `test/gstack-ship-receipt-docs.test.ts`: the carry takes the fork-version bound only on the branch's own line.
- `test/ship-queue-train-stamp.test.ts`: a pin claiming 1.2.4.0 with a row left at the base's 1.2.3.0 stamps, and the row moves to 1.2.4.0. Without the train checks' bound, `stampChain` refused it with `QUEUE_TRAIN_STAMP_MISMATCH`. On main it stamped, but the row stayed at 1.2.3.0.
## [1.93.6.0] - 2026-11-02
**/plan-tune now learns from answers to questions that aren't in its registry, when the skill says which preference the question is about (#3442).** Before, an answer to an ad-hoc question id such as `plan-ceo-review-add-caching` moved no dimension, because only registered ids carry a signal. Most logged answers are on ad-hoc ids, so inferred values stayed near 0.5.
### Added
- **Signal hints at ask time:** a skill can write `<gstack-qid:{id};signal={key}>` in the question and `"signal_key":"<key>"` in the `gstack-question-log` row, choosing the key from the fixed list that `gstack-question-preference --signal-keys` prints. `--derive` uses the hint only when the id has no registry entry, the key is a known signal, and the answer matches one of that signal's options, by the exact key or the key's own words. Hinted answers fall under the same per-day cap as registered ones.
- **`hinted_events`** in the profile, and `(hinted)` on `--trace` rows, so you can see which answers came from hints.
### Changed
- One shared marker parser (`scripts/qid-marker.ts`) now serves the log hook, the preference hook and the Codex session import. A hint never changes auto-decide or one-way-door handling. An unknown hint, or a hint on a registered id, is dropped with `QLOG_SIGNAL_KEY_DROPPED`, and the row is still logged.
### Tests
- New tests that fail on main check that derive moves a dimension for a hinted ad-hoc answer and not for an unhinted one. Unknown keys, choices outside the signal, and registered ids ignore the hint. Hinted answers share the per-day cap. The hooks and the Codex import carry the hint into the log.
## [1.93.5.0] - 2026-11-02
**The weekly OSV scan is clean again: qs and @hono/node-server are pinned past two Medium advisories (#3843).** The 2026-11-02 scan (run 41406416003) flagged qs 6.16.0 (GHSA-6pq8-jhm2-2vcw, CVSS 6.3, a `qs.stringify` comma-format crash) and @hono/node-server 2.1.1 (GHSA-rmxm-3fg6-px4f, CVSS 5.3, a `serveStatic` double decode that skips middleware). Both reach gstack only through the dev and test toolchain (`@anthropic-ai/claude-agent-sdk` pulls in `@modelcontextprotocol/sdk`), and nothing setup, the build or an installed skill runs loads either one. Installs set up since v1.91.231.0 don't contain them at all. Older installs and contributor checkouts that still carry the dev toolchain get the patched versions on their next `./setup`.
### Fixed
- **`package.json` overrides**: `qs` is pinned to 6.16.2 and `@hono/node-server` to 2.1.6, the newest patch releases inside the ranges express, body-parser and the MCP SDK ask for. `bun.lock` now holds one copy of each, and the separate `body-parser/qs` entry is gone.
### Tests
- `test/dependency-security.test.ts`: the lockfile floor check now covers `qs >= 6.16.1` and `@hono/node-server >= 2.1.3`. Both cases failed on main.
## [1.93.4.0] - 2026-11-02
**/ship's finding collector reads severity from a finding's own Severity field, so a severity word in the finding's text no longer raises it.**
### Fixed
- When a reader's finding carries its own `Severity:` field, the collector uses that field. A word like "P0" in the finding's text (a plan phase name, for example) no longer makes it a blocker the reader marked `Blocks: no`. A label leading the line (`CRITICAL:`, `[P0]`, `- High:`, a label heading) still sets the floor, `Blocks: yes` still makes a finding CRITICAL, and the native voice's FIXABLE still collects as CRITICAL. A field that names more than one label ("High/Critical") counts as no field, and a lowercase `severity:` keeps its label. Lines with no Severity field collect as before. (GST-976)
## [1.93.3.0] - 2026-11-02
**The queue now refuses a head whose required targeted review is missing or failed (GST-966).** On gstack-evals#140, the receipt showed `REVIEW_TARGETED: required=auth receipt=legacy verdict=missing` and `gstack-ship-queue ready` still exited 0, so a skipped required reader looked the same as a clean one.
### Fixed
- **`lib/ship-receipt-depth.ts`:** `ready`, `train build` and `land` refuse `QUEUE_RECEIPT_TARGETED_MISSING` when the head needs a targeted class (money, gate, auth or secret) that no done targeted pass in the receipt covers: no pass recorded, a failed or unrun pass, or a pass on other classes. The line says `verdict=failed` when the recorded pass failed. A head that needs no targeted class passes as before. `train build` and `land` print the `REVIEW_TARGETED:` line with the refusal.
### Tests
- `test/gstack-ship-queue.test.ts`: `ready` on an auth head with #140's receipt shape (missing) and with a failed pass refuses with the new code; a done pass and a head that needs no class pass. On main the missing case exits 0.
## [1.93.2.0] - 2026-11-02
**Native cookie import now waits for its browser to actually exit before it reports cleanup, and three more flaky tests from the 2026-11-01 flaky pass are repaired.** Windows drops a Job's process count to zero while terminated Edge processes are still shutting down and still hold the profile's files, so cleanup that trusted the count returned early and the next profile reset failed with a sharing violation.
### Fixed
- **`browse/src/cookie-import-native-job.ts`, `browse/src/cookie-import-native-worker.ts`, `browse/src/cookie-import-native.ts`**: the supervisor holds a handle to every process it sees in the import's Job and counts a process as active until that handle signals exit, not only until the Job's counter drops. The cleanup budget is now 20 s (was 5 s), because terminated Edge took up to 17.8 s to exit on a loaded Windows runner and 2 to 57 ms idle; an idle import returns as soon as its processes are gone. The parent's kill timer allows 2 s beyond that budget. Cleanup that still cannot confirm the exit reports `native_cleanup_failed`, as before. The real-Edge tests take their kill timers and time limits from the same constant.
- **`.gstack/pregate.json`**: the local pregate lane runs `bun test --timeout=30000 {files}`, the 30 s per-test timeout CI and the remote pregate already use, so a case that takes 5 s on a busy machine no longer fails only locally. The runner string is part of the pregate trust grant, so each machine records trust again on its first run after this change (the crew's remote pregate already passes `--allow-repo-commands`).
- **`test/gstack-ship-receipt.test.ts`**: the full `write` case, about 62 git calls, gets a 15 s timeout instead of bun's 5 s default.
- **`test/helpers/coverage-audit-evidence.ts`**: the plan-eng coverage audit detector credits complete reads placed by a literal `wc -c` byte count over in-cwd files, as it already did for `wc -l` (train gate 41209768188).
### Changed
- `test/helpers/docsync-contract.ts` names the missing `documentation_section` when a document-release completion fails the contract, so the next paid red says which field was absent.
## [1.93.1.0] - 2026-11-02
- **`gstack-pregate clear --run-url` accepts a reusable-workflow lane's caller run.** A lane whose workflow file runs only through `workflow_call` (gbrain's `native-locks.yml`, run as a job of `test.yml`) was refused `workflow_mismatch` for every run since v1.92.62.0, because GitHub reports the caller's file as the run's workflow. A run of a workflow in the branch's own `.github/workflows/` with a job that says `uses: ./.github/workflows/<lane workflow>` now clears the lane, a job link inside that run too, as long as the lane's own job succeeded under the calling job (`<calling job> / <lane job>`, else `caller_job_skipped`), with callers read at pregate.json's recorded tree; the red and unfinished-run refusals still apply. Fix lines only suggest `gh workflow run` for a file with a `workflow_dispatch` trigger; otherwise they name the callers. An unrelated workflow still refuses. `test/pregate-clear-runs.test.ts` failed before the fix (GST-971).
## [1.93.0.0] - 2026-11-02
**`gstack-clean` reclaims the throwaway files that pile up in a repository's `.gstack/` folder, and changes nothing unless you pass `--apply`** (#2627). Until now nothing removed old codex scratch files or trimmed the browse logs, so the folder only grew (the reporter measured 8.3 MB in six weeks), and the only way out was deleting the whole folder, which also deletes your cookies, Chromium profile and browser skills.
+1 -1
View File
@@ -432,7 +432,7 @@ eval files, and misses the strict classifier. No API keys needed.
- **Catalog budget** (`test/catalog-budget.test.ts`) — Caps the aggregate discovery surface: the sum of every skill's frontmatter `name` + `description` (what every host loads at discovery, every session) must stay under 1,423 token-equivalents, with a 260-byte per-skill cap. Counting goes through the shared census in `test/helpers/skill-census.ts` (physical files vs authored skills vs registry entries — three deliberately different counts). Adding a skill? The failure message carries the re-measure + ratchet protocol.
- **Context-budget ratchet** (`test/context-budget-ratchet.test.ts`) — CI ceilings on the two token ledgers the catalog budget doesn't cover: the always-on full-frontmatter aggregate and each skill's per-invocation eager tokens (SKILL.md + forced-read references), graded against `test/fixtures/context-budget.json` via `lib/context-bill.ts`. New skills fail until they have a ceiling; ceilings for removed skills must be pruned. Legitimate growth or a landed reduction: re-run `bun test/helpers/capture-context-budget.ts` and commit the refreshed fixture in the same commit, so the change is a visible decision in the diff.
- **Skill-to-bin drift ratchet** (`test/skill-bin-drift.test.ts`) — `scripts/skill-bin-drift.ts` statically reads every gstack bin call in skill templates, carved sections, references and the Claude-host renders (it never executes a bin) and fails with `DRIFT_UNKNOWN_BIN` or `DRIFT_UNKNOWN_FLAG` at file:line when a skill names a bin missing from `bin/` or a flag that bin's parser does not accept. Known misses live in `test/fixtures/skill-bin-drift-allowlist.json`, which only shrinks: a new finding fails, and so does an entry that no longer reproduces. Check one skill with `bun run scripts/skill-bin-drift.ts --skill <name>`; bins with permissive parsers or no usage text are reported, never failed.
- **Dependency security regressions** (`test/dependency-security.test.ts`) — Run `bun test test/dependency-security.test.ts` to check the resolved `sharp` and `adm-zip` version floors, load Sharp, verify ordinary ZIP extraction, and reject extraction through destination-file and destination-directory symlinks. The symlink cases skip Windows. These checks complement the OSV scan; they do not change its existing exceptions.
- **Dependency security regressions** (`test/dependency-security.test.ts`) — Run `bun test test/dependency-security.test.ts` to check the resolved `sharp`, `adm-zip`, `qs` and `@hono/node-server` version floors, load Sharp, verify ordinary ZIP extraction, and reject extraction through destination-file and destination-directory symlinks. The symlink cases skip Windows. These checks complement the OSV scan; they do not change its existing exceptions.
### Tier 2: E2E via `claude -p`
+1 -1
View File
@@ -85,7 +85,7 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of
Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up.
- **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2.
- **Plan-tune `{skill}-{slug}` ids never reach a signal (#3442, split from #2623)** — the label-to-key capture (#3394), `contributing_events` and the per-day bound (#3397) closed the rest of #2623. Remaining: `scripts/resolvers/question-tuning.ts` lets skills use `{skill}-{slug}` ids, which have no registry entry and so no `signal_key`; those answers never move a dimension. Acceptance: `{skill}-{slug}` ids get a hinted fallback to a registered signal key. Needs a design call on the key source. Credit @v639dragoon. **Effort:** M. **Priority:** P2.
- **Plan-tune qid marker follow-ups (#3442 review notes, filed 2026-11-02)**: `QID_MARKER_RE` in `scripts/qid-marker.ts` drops the whole marker when the `;signal=` tail is malformed (an underscore, a space, an empty key). Match the id first and drop only a bad hint. `question-preference-hook.ts` still looks up the raw marker id, while the log hook lowercases it. Use `parseQidMarker(...).id` in both hooks. Add hinted-marker cases to `test/question-preference-hook.test.ts`. **Effort:** S. **Priority:** P3.
- **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3.
- **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills/<skill>/SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3.
- **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3.
+1 -1
View File
@@ -1 +1 @@
1.93.0.0
1.93.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.93.0.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.93.11.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,
+2 -2
View File
@@ -67,9 +67,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -1
View File
@@ -138,7 +138,7 @@ for SESSION_FILE in "${SESSION_FILES[@]}"; do
const text = String(e.payload?.message || "");
if (!text) continue;
// Detect D-numbered brief or marker. Markers are sufficient on their own.
const markerMatch = text.match(/<gstack-qid:([a-z0-9-]{1,64})>/i);
const markerMatch = text.match(/<gstack-qid:([a-z0-9-]{1,64})(?:;signal=([a-z0-9-]{1,64}))?>/i);
const dMatch = text.match(/^D\d+[\.\d]*\s*[—\-]\s*(.+?)$/m);
if (!markerMatch && !dMatch) continue;
@@ -199,6 +199,7 @@ for SESSION_FILE in "${SESSION_FILES[@]}"; do
options_count: options.length || 1,
user_choice: userChoice.slice(0, 64),
...(recommended ? { recommended: recommended.slice(0, 64) } : {}),
...(markerMatch?.[2] ? { signal_key: markerMatch[2].toLowerCase() } : {}),
source,
session_id: sessionId,
// Use ts_nanos+ts shape from the event itself if available; else null.
+10 -5
View File
@@ -564,6 +564,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
const days = new Set();
let count = 0;
let signalEvents = 0;
let hintedEvents = 0;
for (const line of lines) {
let e;
try { e = JSON.parse(line); } catch { continue; }
@@ -576,16 +577,18 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
const day = when && !Number.isNaN(when.getTime()) ? when.toISOString().slice(0, 10) : null;
if (day) days.add(day);
const def = Object.hasOwn(QUESTIONS, e.question_id) ? QUESTIONS[e.question_id] : undefined;
const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : {};
const hinted = !def && typeof e.signal_key === "string" && Object.hasOwn(SIGNAL_MAP, e.signal_key);
const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : hinted ? SIGNAL_MAP[e.signal_key] : {};
const deltas = Object.hasOwn(byChoice, e.user_choice) ? byChoice[e.user_choice] : [];
if (!Array.isArray(deltas) || deltas.length === 0) continue;
signalEvents++;
if (hinted) hintedEvents++;
const key = day || "undated";
const sums = buckets.get(key) || {};
buckets.set(key, sums);
for (const d of deltas) {
sums[d.dim] = (sums[d.dim] || 0) + d.delta;
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta });
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta, hinted });
}
for (const dim of new Set(deltas.map((d) => d.dim))) contributing[dim] = (contributing[dim] ?? 0) + 1;
}
@@ -598,7 +601,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
totals[dim] = (totals[dim] ?? 0) + bounded;
}
}
return { cap, totals, contributing, rows, capped, count, signalEvents, skills, qids, days };
return { cap, totals, contributing, rows, capped, count, signalEvents, hintedEvents, skills, qids, days };
}
'
@@ -635,7 +638,7 @@ do_derive() {
lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
}
const { totals, contributing, count, signalEvents, skills, qids, days } =
const { totals, contributing, count, signalEvents, hintedEvents, skills, qids, days } =
accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS);
const values = {};
@@ -653,6 +656,8 @@ do_derive() {
// evidence' from 'balanced evidence': both print 0.5, which is how
// five 0.5s sat beside sample_size 1286 (#2623).
contributing_events: contributing,
// Signal events that came from an unregistered id's signal hint (#3442).
hinted_events: hintedEvents,
diversity: {
skills_covered: skills.size,
question_ids_covered: qids.size,
@@ -712,7 +717,7 @@ do_trace() {
} else {
console.log('TRACE: ' + rows.length + ' events for ' + target);
for (const r of rows) {
console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')');
console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')' + (r.hinted ? ' (hinted)' : ''));
}
for (const c of acc.capped.filter((c) => c.dim === target)) {
console.log(' day ' + clean(c.day) + ': ' + signed(c.sum, 3) + ' capped to ' + signed(c.bounded, 3));
+1
View File
@@ -307,6 +307,7 @@ gstack_result_anchor() {
BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;;
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_RECEIPT_TARGETED_MISSING) printf '%s' 'queue-receipt-targeted-missing' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;;
QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;;
+9 -5
View File
@@ -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
View File
@@ -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`);
+33
View File
@@ -23,6 +23,12 @@
# question_id, the clicked UI label is mapped to its
# registered option key (#2623) and kept in choice_label.
# Unmatched labels and other sources stay as given.
# signal_key — optional SIGNAL_MAP key an unregistered question_id's
# answer speaks to (#3442; `gstack-question-preference
# --signal-keys` lists them). Kept only when it is a
# signal key and the id is unregistered; the choice is
# then mapped to that signal's option keys. A dropped
# hint logs the row anyway (QLOG_SIGNAL_KEY_DROPPED).
# recommended — option key the agent recommended (optional; mapped
# the same way on hook-captured events)
# followed_recommendation — bool (optional — computed if both present)
@@ -197,6 +203,32 @@ if (j.source === 'hook') {
}
}
// A signal hint on an unregistered id (#3442) is kept only when it names a
// SIGNAL_MAP key; the choice is then mapped to that signal's option keys
// (exact key or its own words). A dropped hint never fails the log.
if (j.signal_key !== undefined) {
const { SIGNAL_MAP, resolveHintedOptionKey } = await import(process.env.GSTACK_SCRIPTS_DIR + '/psychographic-signals.ts');
const { getQuestion } = await import(process.env.GSTACK_SCRIPTS_DIR + '/question-registry.ts');
const hint = typeof j.signal_key === 'string' ? j.signal_key.toLowerCase() : '';
let reason = '';
if (!Object.hasOwn(SIGNAL_MAP, hint)) reason = 'it is not a signal key; list them with gstack-question-preference --signal-keys';
else if (getQuestion(j.question_id)) reason = 'a registered id keeps its own signal';
if (reason) {
process.stderr.write('gstack-question-log: logged the answer without its signal_key: ' + reason + ' (QLOG_SIGNAL_KEY_DROPPED)\n');
delete j.signal_key;
} else {
j.signal_key = hint;
const key = resolveHintedOptionKey(hint, j.user_choice);
if (key !== undefined && key !== j.user_choice) {
j.choice_label = j.user_choice;
j.user_choice = key;
}
if (j.recommended !== undefined) {
j.recommended = resolveHintedOptionKey(hint, j.recommended) ?? j.recommended;
}
}
}
// followed_recommendation — compute if both sides present. An __unknown__
// choice means extraction failed, not that the user rejected the
// recommendation — leave the field absent so metrics can't be poisoned.
@@ -233,6 +265,7 @@ if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then
fi
exit 1
fi
grep -h 'QLOG_SIGNAL_KEY_DROPPED' "$TMPERR" >&2 || true
LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
+16
View File
@@ -19,6 +19,8 @@
# --read → dump preferences JSON
# --clear [<id>] → clear one or all preferences
# --stats → short summary (inert one-way prefs counted separately)
# --signal-keys → one line per signal key with its option keys, the
# menu for an unregistered id's ;signal= hint (#3442)
#
# User-origin gate
# ----------------
@@ -365,8 +367,22 @@ do_stats() {
")
}
do_signal_keys() {
cd "$ROOT_DIR"
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
import('./scripts/psychographic-signals.ts').then((m) => {
console.log('Optional signal hint for an unregistered question_id whose answer shows one of these preferences:');
console.log('write <gstack-qid:{question_id};signal={signal_key}>, start each option label with one of the');
console.log('option keys listed after that signal key, and add a signal_key field with that key to the gstack-question-log call.');
console.log('Omit the hint when no line fits.');
for (const line of m.signalKeyLines()) console.log(line);
}).catch(err => { console.error('signal-keys:', err.message); process.exit(1); });
"
}
case "$CMD" in
--check) do_check "$@" ;;
--signal-keys) do_signal_keys ;;
--write) do_write "$@" ;;
--read|"") do_read ;;
--clear) do_clear "$@" ;;
+20 -3
View File
@@ -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
@@ -21,6 +21,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { locate, readRecord } from '../lib/approval-record';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { runScriptSync } from '../lib/script-invocation';
import { collectCommit, collectLive } from '../lib/review-collect';
@@ -54,12 +55,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()}`;
@@ -92,11 +98,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`);
@@ -145,7 +162,7 @@ function main(): number {
const plan = planReview({
...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(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 -3
View File
@@ -37,6 +37,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { branchSides, type BranchSide } from '../lib/stamp-only';
import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
@@ -495,7 +496,7 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[]
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
* (GST-343, #3606), into the product patch-id at one head only.
*/
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]> }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]>; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
if (changedRaw === undefined) return undefined;
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
@@ -507,7 +508,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
const versions = new Map<string, string | null>();
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates } : undefined);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
@@ -620,7 +621,7 @@ function cmdCarry(rest: string[], json: boolean): number {
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const touched = new Set([...listed[0], ...listed[1]]);
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] });
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
const { changed } = spec;
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
+2 -2
View File
@@ -64,10 +64,10 @@ function main(argv: string[]): number {
if (predSha || predPr || predRef) predecessor = { pr: predPr ?? predecessor?.pr ?? null, recordedHead: predSha ?? predecessor?.recordedHead ?? null, ref: predRef ?? null };
const policyFrom = flag(argv, '--policy-from') ?? 'base';
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
let loaded: LoadedPolicy;
if (policyFrom === 'head') loaded = loadPolicyFromHead(repoRoot);
else {
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
const l = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(l)) { process.stderr.write(policyUnavailableLine('gstack-tree-receipt', base, l.detail) + '\n'); return EXIT.refused; }
loaded = l;
@@ -76,7 +76,7 @@ function main(argv: string[]): number {
const receipt = buildReceipt({
repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label,
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'),
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), baseRef: `origin/${base}`,
});
if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; }
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt));
+26 -1
View File
@@ -47,6 +47,8 @@ export function parseNativeCookieDiagnostic(value: unknown): NativeCookieDiagnos
return diagnostic;
}
export const NATIVE_CLEANUP_MS = 20_000;
export interface NativeCookieJob {
name: string;
terminate(): void;
@@ -64,10 +66,13 @@ export async function createNativeCookieJob(): Promise<NativeCookieJob> {
let stage: NativeCookieDiagnostic['stage'] = 'job_create';
let handle: number | bigint = 0;
let closed = false;
const held = new Map<number, number | bigint>();
const close = () => {
if (closed) return;
closed = true;
try {
for (const member of held.values()) api.CloseHandle(member);
held.clear();
if (handle && !api.CloseHandle(handle)) throw new NativeCookieJobError('job_close', api.GetLastError());
} finally {
closeLibrary();
@@ -93,7 +98,25 @@ export async function createNativeCookieJob(): Promise<NativeCookieJob> {
const accounting = Buffer.alloc(48);
if (closed) throw new NativeCookieJobError('job_query');
if (!api.QueryInformationJobObject(handle, 1, ptr(accounting), accounting.byteLength, null)) throw new NativeCookieJobError('job_query', api.GetLastError());
return accounting.readUInt32LE(40);
const listed = Buffer.alloc(8 + 8 * 1024);
if (!api.QueryInformationJobObject(handle, 3, ptr(listed), listed.byteLength, null)) throw new NativeCookieJobError('job_query', api.GetLastError());
for (let index = 0; index < Math.min(listed.readUInt32LE(4), 1024); index++) {
const pid = Number(listed.readBigUInt64LE(8 + 8 * index));
if (held.has(pid)) continue;
const member = api.OpenProcess(0x00101000, 0, pid);
if (!member) continue;
const inJob = Buffer.alloc(4);
if (api.IsProcessInJob(member, handle, ptr(inJob)) && inJob.readInt32LE(0) !== 0) held.set(pid, member);
else api.CloseHandle(member);
}
let running = 0;
for (const [pid, member] of held) {
const state = api.WaitForSingleObject(member, 0);
if (state === 0x102) running++;
else if (state === 0) { api.CloseHandle(member); held.delete(pid); }
else throw new NativeCookieJobError('job_query', api.GetLastError());
}
return Math.max(accounting.readUInt32LE(40), running);
},
close,
};
@@ -150,6 +173,8 @@ async function openKernel() {
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
AssignProcessToJobObject: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 },
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
WaitForSingleObject: { args: [FFIType.u64, FFIType.u32], returns: FFIType.u32 },
TerminateJobObject: { args: [FFIType.u64, FFIType.u32], returns: FFIType.i32 },
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
GetLastError: { args: [], returns: FFIType.u32 },
+4 -4
View File
@@ -1,7 +1,7 @@
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { writeSync } from 'node:fs';
import { createNativeCookieJob, joinNativeCookieJob, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job';
import { createNativeCookieJob, joinNativeCookieJob, NATIVE_CLEANUP_MS, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job';
import type { PlaywrightCookie } from './cookie-import-browser';
import { emptyBunConfig } from './bun-child-config';
@@ -114,11 +114,11 @@ export async function superviseNativeCookieImport(
void member.closed.then(() => { closed = true; }, () => { closed = true; });
while (!reply && !closed && now() < deadline && !dependencies.signal?.aborted) await sleep(Math.min(20, deadline - now()));
reply ??= { error: now() >= deadline ? 'native_timeout' : 'native_failed' };
const cleanupDeadline = now() + 5_000;
const cleanupDeadline = now() + NATIVE_CLEANUP_MS;
const graceDeadline = now() + ('cookies' in reply ? 2_000 : 0);
while ((!closed || job.activeProcesses() !== 0) && now() < graceDeadline) await sleep(20);
while ((job.activeProcesses() !== 0 || !closed) && now() < graceDeadline) await sleep(20);
if (job.activeProcesses() !== 0) job.terminate();
while ((!closed || job.activeProcesses() !== 0) && now() < cleanupDeadline) await sleep(20);
while ((job.activeProcesses() !== 0 || !closed) && now() < cleanupDeadline) await sleep(20);
if (!closed || job.activeProcesses() !== 0) return { error: 'native_cleanup_failed' };
return reply;
} catch (error) {
+2 -1
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser';
import { emptyBunConfig, findWindowsBunExe } from './bun-child-config';
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity';
import { NATIVE_CLEANUP_MS } from './cookie-import-native-job';
type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
@@ -123,7 +124,7 @@ export async function importNativeCookies(options: {
const timer = setTimeout(() => {
worker.kill();
reject(new CookieImportError('Native cookie cleanup could not be confirmed within its deadline.', 'native_cleanup_failed'));
}, Math.max(0, deadline + 5_000 - Date.now()));
}, Math.max(0, deadline + NATIVE_CLEANUP_MS + 2_000 - Date.now()));
worker.stdout.setEncoding('utf8');
worker.stdout.on('data', chunk => {
output += chunk;
+67 -13
View File
@@ -10,7 +10,7 @@ import { Database } from 'bun:sqlite';
import { dlopen, FFIType, ptr } from 'bun:ffi';
import { nativeBrowserPaths } from '../src/cookie-import-native';
import { NATIVE_BROWSER_VERSION_COMMAND } from '../src/cookie-import-native-integrity';
import { createNativeCookieJob, joinNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job';
import { createNativeCookieJob, joinNativeCookieJob, NATIVE_CLEANUP_MS, NativeCookieJobError, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job';
import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, superviseNativeCookieImport, type NativeCookieMember, type NativeCookieReply, type NativeCookieRequest } from '../src/cookie-import-native-worker';
import { decodeNativeCommandLine } from './fixtures/native-cookie-process-observer';
import { createFixtureDeleteLease, FixtureDeleteError } from './fixtures/native-cookie-delete-lease';
@@ -334,6 +334,8 @@ afterAll(() => {
}
}, 15_000);
const NATIVE_SUPERVISOR_MS = 25_000 + NATIVE_CLEANUP_MS;
const request: NativeCookieRequest = {
nodeExecutable: 'C:\\fixture\\node.exe',
nodeArchitecture: process.arch,
@@ -349,6 +351,8 @@ const request: NativeCookieRequest = {
function kernelContract(mode: string) {
const script = `
const calls = [];
const polls = {};
let listings = 0;
globalThis.__nativeCookieFfi = {
FFIType: { ptr: 'ptr', u64: 'u64', u32: 'u32', i32: 'i32' },
ptr: buffer => buffer,
@@ -358,8 +362,18 @@ function kernelContract(mode: string) {
CreateJobObjectW: (security, name) => { calls.push(['create', security, name.toString('utf16le')]); return 42; },
OpenJobObjectW: (access, inherit, name) => { calls.push(['open', access, inherit, name.toString('utf16le')]); return 42; },
SetInformationJobObject: (handle, kind, buffer, length) => { calls.push(['limits', handle, kind, length, buffer.readUInt32LE(16)]); return 1; },
QueryInformationJobObject: (handle, kind, buffer, length) => { calls.push(['query', handle, kind, length]); buffer.writeUInt32LE(3, 40); return 1; },
OpenProcess: (access, inherit, pid) => { calls.push(['open-process', access, inherit, pid === process.pid]); return 999; },
QueryInformationJobObject: (handle, kind, buffer, length) => {
calls.push(['query', handle, kind, length]);
if (kind === 1) buffer.writeUInt32LE(${mode === 'exiting' ? 0 : 3}, 40);
if (kind === 3 && ${JSON.stringify(mode)} === 'exiting' && listings++ === 0) {
buffer.writeUInt32LE(3, 4);
[101, 102, 103].forEach((pid, index) => buffer.writeBigUInt64LE(BigInt(pid), 8 + 8 * index));
}
return 1;
},
OpenProcess: (access, inherit, pid) => { calls.push(['open-process', access, inherit, pid === process.pid]); return ${mode === 'exiting' ? '1000 + pid' : 999}; },
IsProcessInJob: (member, job, result) => { calls.push(['in-job', member, job]); result.writeInt32LE(member === 1103 ? 0 : 1, 0); return 1; },
WaitForSingleObject: (member, milliseconds) => { calls.push(['wait', member, milliseconds]); polls[member] ??= member - 1100; return polls[member]-- > 0 ? 0x102 : 0; },
AssignProcessToJobObject: (job, process) => { calls.push(['assign', job, process]); return ${mode === 'join-fail' ? 0 : 1}; },
TerminateJobObject: (job, code) => { calls.push(['terminate', job, code]); return 1; },
CloseHandle: handle => { calls.push(['close', handle]); return 1; },
@@ -378,6 +392,11 @@ function kernelContract(mode: string) {
const active = job.activeProcesses();
job.terminate(); job.close(); job.close();
console.log(JSON.stringify({ name: job.name, active, calls }));
} else if (${JSON.stringify(mode)} === 'exiting') {
const job = await createNativeCookieJob();
const active = [job.activeProcesses(), job.activeProcesses(), job.activeProcesses()];
job.close();
console.log(JSON.stringify({ active, calls }));
} else {
let error, diagnostic;
try { await joinNativeCookieJob('Local\\\\gstack-cookie-12345678-1234-1234-1234-123456789abc'); } catch (caught) { error = caught.message; diagnostic = caught.diagnostic; }
@@ -416,6 +435,15 @@ describe('production Windows Job Object API contract', () => {
expect(result.calls.filter((call: unknown[]) => call[0] === 'close')).toEqual([['close', 42]]);
});
test('a member the Job no longer counts stays active until its process handle signals exit', () => {
const result = kernelContract('exiting');
expect(result.active).toEqual([2, 1, 0]);
expect(result.calls).toContainEqual(['query', 42, 3, 8 + 8 * 1024]);
expect(result.calls).toContainEqual(['open-process', 0x00101000, 0, false]);
expect(result.calls.filter((call: unknown[]) => call[0] === 'in-job')).toEqual([['in-job', 1101, 42], ['in-job', 1102, 42], ['in-job', 1103, 42]]);
expect(result.calls.filter((call: unknown[]) => call[0] === 'close')).toEqual([['close', 1103], ['close', 1101], ['close', 1102], ['close', 42]]);
});
test('member assigns its own handle, never a discovered PID', () => {
const result = kernelContract('join');
expect(result.error).toBeUndefined();
@@ -921,10 +949,36 @@ describe('owned native-cookie lifecycle', () => {
expect(now).toBeLessThan(30_000);
});
test('cleanup waits for a member that is still exiting after the Job count reads zero', async () => {
let now = 0;
let held = false;
let settleReply!: (reply: NativeCookieReply) => void;
let settleClose!: () => void;
const result = await superviseNativeCookieImport(request, {
now: () => now,
sleep: async milliseconds => {
now += milliseconds;
if (now >= 20) settleReply({ cookies: [] });
if (now >= 200) settleClose();
},
createJob: async () => ({
name: 'synthetic-owned-job', close() {}, terminate() {},
activeProcesses: () => {
if (now < 100) held = true;
return now < 100 || (held && now < 5_000) ? 1 : 0;
},
}),
startMember: () => ({ result: new Promise(resolve => { settleReply = resolve; }), closed: new Promise(resolve => { settleClose = resolve; }), stop() {} }),
});
expect(result).toEqual({ cookies: [] });
expect(now).toBeGreaterThanOrEqual(5_000);
expect(now).toBeLessThan(NATIVE_CLEANUP_MS);
});
test('unconfirmed termination is reported as cleanup failure, never success', async () => {
const run = simulation({ retainAfterTerminate: true });
expect(await run.run).toEqual({ error: 'native_cleanup_failed' });
expect(run.state()).toMatchObject({ time: 30_000, terminated: 1, stopped: 1, jobClosed: 1 });
expect(run.state()).toMatchObject({ time: 25_000 + NATIVE_CLEANUP_MS, terminated: 1, stopped: 1, jobClosed: 1 });
});
test('a process crash cannot strand remaining members', async () => {
@@ -1029,11 +1083,11 @@ function safeLaunchEvidence(file: string): object {
}
function nativeSupervisor(input: NativeCookieRequest, env: NodeJS.ProcessEnv) {
const cleanupDeadline = performance.now() + 30_000;
const cleanupDeadline = performance.now() + NATIVE_SUPERVISOR_MS;
const child = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }));
let output = '';
child.stdout.on('data', chunk => { output += chunk; });
const timer = setTimeout(() => child.kill(), 30_000);
const timer = setTimeout(() => child.kill(), NATIVE_SUPERVISOR_MS);
const done = new Promise<NativeCookieReply>((resolve, reject) => {
child.once('error', reject);
child.once('close', () => {
@@ -1131,7 +1185,7 @@ describe('native Windows process qualification', () => {
}
for (const parent of createdParents.reverse()) rmdirSync(parent);
}
}, 65_000);
}, 2 * NATIVE_SUPERVISOR_MS + 5_000);
test.skipIf(process.platform !== 'win32')('a locked real Edge profile leaves its existing owner alive', async () => {
const node = Bun.which('node');
@@ -1166,7 +1220,7 @@ describe('native Windows process qualification', () => {
await contender?.done.catch(() => {});
await owner.done.catch(() => {});
}
}, 65_000);
}, 2 * NATIVE_SUPERVISOR_MS + 5_000);
for (const mode of ['normal-close', 'stalled-close']) {
test.skipIf(process.platform !== 'win32')(`real Edge synthetic profile: ${mode} returns only after the owned browser exits`, async () => {
@@ -1184,7 +1238,7 @@ describe('native Windows process qualification', () => {
let output = '';
supervisor.stdout.on('data', chunk => { output += chunk; });
const started = Date.now();
const deadline = setTimeout(() => supervisor.kill(), 30_000);
const deadline = setTimeout(() => supervisor.kill(), NATIVE_SUPERVISOR_MS);
supervisor.stdin.write(JSON.stringify({ ...request, nodeExecutable: node, playwrightEntry, executablePath: edge, userDataDir: path.join(fixture, 'User Data'), deadline: started + 25_000, qualifiedBunVersions: [Bun.version] }) + '\n');
try {
await closed;
@@ -1198,13 +1252,13 @@ describe('native Windows process qualification', () => {
expect(browser.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false);
expect(browser.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false);
expect(alive(browser.pid)).toBe(false);
expect(Date.now() - started).toBeLessThan(30_000);
expect(Date.now() - started).toBeLessThan(NATIVE_SUPERVISOR_MS);
} finally {
clearTimeout(deadline);
supervisor.kill();
await closed;
}
}, 35_000);
}, NATIVE_SUPERVISOR_MS + 5_000);
}
for (const mode of ['timeout', 'owner-exit', 'worker-crash']) {
@@ -1585,7 +1639,7 @@ describe('native Windows launch diagnostics', () => {
expect(directObservation.observedCommandLine).toMatchObject({ available: true });
expect(containedObservation.folderEvidence.directoriesAfterProbe).toEqual(containedObservation.folderEvidence.directoriesBefore);
expect(directObservation.folderEvidence.directoriesAfterProbe).toEqual(directObservation.folderEvidence.directoriesBefore);
}, 65_000);
}, 2 * NATIVE_SUPERVISOR_MS + 5_000);
}
for (const state of ['preserved', 'fresh']) {
@@ -1640,6 +1694,6 @@ describe('native Windows launch diagnostics', () => {
expect(secondObservation.observedCommandLine).toMatchObject({ available: true });
expect('error' in second ? second.error : null).not.toBe('native_cleanup_failed');
if (state === 'fresh') expect(firstObservation.folderEvidence.directoriesBefore).toEqual(secondObservation.folderEvidence.directoriesBefore);
}, 65_000);
}, 2 * NATIVE_SUPERVISOR_MS + 5_000);
}
});
+4 -4
View File
@@ -31,6 +31,7 @@
"playwright-core@1.62.1": "patches/playwright-core@1.62.1.patch",
},
"overrides": {
"@hono/node-server": "2.1.6",
"@modelcontextprotocol/sdk": "1.32.0",
"adm-zip": "^0.6.1",
"fast-uri": "3.1.9",
@@ -38,6 +39,7 @@
"image-size": "2.0.4",
"ip-address": "10.7.4",
"proxy-addr": "2.0.8",
"qs": "6.16.2",
"sharp": "0.35.5",
},
"packages": {
@@ -65,7 +67,7 @@
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="],
"@hono/node-server": ["@hono/node-server@2.1.6", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ=="],
"@huggingface/jinja": ["@huggingface/jinja@0.5.10", "", {}, "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw=="],
@@ -483,7 +485,7 @@
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="],
"qs": ["qs@6.16.2", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
@@ -597,8 +599,6 @@
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"body-parser/qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="],
"dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
+2 -2
View File
@@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -59,9 +59,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+8 -1
View File
@@ -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.
@@ -1,7 +0,0 @@
**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.
+2 -2
View File
@@ -53,9 +53,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -78,9 +78,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -72,9 +72,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+114 -14
View File
@@ -651,15 +651,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 +693,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 +1274,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 +1794,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 +1913,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
View File
@@ -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
+2 -1
View File
@@ -89,6 +89,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an
"sample_size": 47,
"signal_events": 19,
"contributing_events": {"scope_appetite": 9, "risk_tolerance": 6, "...": "..."},
"hinted_events": 3,
"diversity": {
"skills_covered": 5,
"question_ids_covered": 14,
@@ -105,7 +106,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an
}
```
`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace <dim>` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436).
`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace <dim>` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436). An unregistered `{skill}-{slug}` id moves a dimension only through a signal hint (#3442): the asked question carries `<gstack-qid:{id};signal={signal_key}>` and the log row a matching `signal_key`, chosen from `gstack-question-preference --signal-keys`. Derive uses the hint only when the id has no registry entry, the key is in the signal map and the answer matches one of that signal's option keys; `hinted_events` counts those signal events, and `--trace` marks their rows `(hinted)`.
**Diversity check** (Codex #13): `inferred` is considered "enough data" only when `sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`. Below this, `/plan-tune profile` shows "not enough observed data yet" instead of a potentially-misleading inferred value.
+1 -1
View File
@@ -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
View File
@@ -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 |
+12 -3
View File
@@ -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 {files}`); 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, 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) |
| `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`) |
@@ -762,7 +762,15 @@ workflow file is the lane's own `<workflow.yml>`. A run of another workflow
refuses with `REUSE: refused reason=workflow_mismatch — run <id> attempt <n>
ran .github/workflows/<other>.yml, not .github/workflows/<lane's>.yml`; a
run gh cannot read refuses with `api_error`, and a URL that is not an Actions
run URL or id on this repository with `run_ref_invalid`. The run must also
run URL or id on this repository with `run_ref_invalid`. A reusable-workflow
lane (one whose file another workflow calls through `workflow_call`) also clears from a run
of a caller: a workflow in the branch's own `.github/workflows/` with a job
that says `uses: ./.github/workflows/<lane's>.yml` (GST-971), read at
pregate.json's recorded tree, as long as the lane's own job succeeded in the
run under that calling job (`<calling job> / <lane job>`); else it refuses
with `caller_job_skipped`. Fix lines only suggest `gh workflow run` for
a file with a `workflow_dispatch` trigger.
The run must also
have finished green (GSTA-203 PR 8): a run that is still queued or running
refuses with `run_in_progress`, and one that concluded anything but `success`
(`failure`, `cancelled`, `timed_out`, …) with `run_not_success`; each fix line
@@ -770,7 +778,8 @@ says to rerun the workflow and clear with the green run. The check reads the
run's newest attempt, so a `/job/<id>` or `/attempts/<n>` link is judged by
the whole run, not that job or attempt, and the lane records
`https://github.com/<repo>/actions/runs/<id>/attempts/<n>` for the attempt it
judged rather than the link you passed. The job, tree and age are not
judged rather than the link you passed. The job (apart from a caller run's
`<calling job> / <lane job>` above), tree and age are not
checked there; that is what a run profile adds. `test_backend/<backend>` lanes and `--bundle-lane` are
unchanged. When the base config or the GitHub origin cannot be read, `--run`
fails, and so do `--run-url` and `--bundle-lane` unless the base was read and
+26 -7
View File
@@ -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`).
@@ -384,10 +401,11 @@ block without new floor words. The reader is read-only, and `NO FINDINGS` is a
result. `gstack-review-dispatch render --role targeted --classes <classes>`
renders its brief; `verify --targeted-out <file>` writes the record that
`gstack-ship-receipt write --review-targeted @<file>` puts in the receipt.
`gstack-ship-queue ready` and `train build` print
`gstack-ship-queue ready`, `train build` and `land` print
`REVIEW_TARGETED: required=<classes|none> receipt=<classes|none|legacy>
verdict=ok|missing` and refuse nothing yet; enforcement comes in a later
release.
verdict=ok|missing|failed` and refuse
[`QUEUE_RECEIPT_TARGETED_MISSING`](troubleshooting.md#queue-receipt-targeted-missing)
when a required class has no done pass covering it.
Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit
21, gate 18, auth 3 and secret 8. The gate class first used the gate paths,
@@ -539,10 +557,11 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
3. `collect` hashes each reader's raw output and appends its findings to
`merged.jsonl` with the label as written (a JSON line's `severity`, or the most
severe of a prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE
wherever they stand, headings and table rows included; a top-level list item under a
wherever they stand, headings and table rows included, except that a line with its own `Severity` field takes that
field's label (any letter case; a field stating more than one label, "High/Critical", counts as none), so a severity word in the finding's text does not raise it, and a label leading the line stays its floor (GST-976); a top-level list item under a
label-only heading takes the stronger of the two). A `Severity:` or `Blocks:` line
continues the finding above it until a list item, heading, bold paragraph opener or a
second line for the same field starts another: `Severity` raises its label,
second line for the same field starts another: `Severity` sets its label (a label leading the finding stays its floor),
`Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline
`Blocks: yes` is CRITICAL too, and with no finding above it is one. The native
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167). An unnumbered, unlabeled bullet with no file:line
+19 -9
View File
@@ -246,8 +246,10 @@ result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
`QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that
head: /ship runs before every READY ([details](#ship-before-ready)). It also
refuses `QUEUE_RECEIPT_DEPTH_RAISED` when the receipt's review depth is below
what the head needs and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner
exception the receipt records does not stand ([the ship receipt](#ship-receipt)).
what the head needs, `QUEUE_RECEIPT_TARGETED_MISSING` when a targeted class
the head needs has no done targeted pass in the receipt, and
`QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner exception the receipt records
does not stand ([the ship receipt](#ship-receipt)).
Send the block as printed.
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
@@ -683,8 +685,10 @@ slot. The queue enforces this at every step from the PR body alone:
head being READY'd (or its `pr` is another PR); the informational
`SHIP_RECEIPT:` line still prints. It refuses `QUEUE_RECEIPT_DEPTH_RAISED`
(with a `REVIEW_DEPTH:` line) when the receipt records a review depth below
what the head needs, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner
exception it records does not stand ([the ship receipt](#ship-receipt)).
what the head needs, `QUEUE_RECEIPT_TARGETED_MISSING` (with a
`REVIEW_TARGETED:` line) when a targeted class the head needs has no done
targeted pass in the receipt, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an
owner exception it records does not stand ([the ship receipt](#ship-receipt)).
There is no flag around it.
- `train build` refuses a pin whose PR has no current receipt with the same
codes, naming the PR, before anything is written, pushed or dispatched,
@@ -803,9 +807,10 @@ its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
`QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship
receipt for the pin; see [/ship before READY](#ship-before-ready)),
`QUEUE_RECEIPT_DEPTH_RAISED` and `QUEUE_RECEIPT_EXCEPTION_INVALID` (the
receipt's review depth is below what the pin needs, or an owner exception it
records does not stand; see [the ship receipt](#ship-receipt)),
`QUEUE_RECEIPT_DEPTH_RAISED`, `QUEUE_RECEIPT_TARGETED_MISSING` and
`QUEUE_RECEIPT_EXCEPTION_INVALID` (the receipt's review depth is below what
the pin needs, a targeted class the pin needs has no done targeted pass, or an
owner exception it records does not stand; see [the ship receipt](#ship-receipt)),
`QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
`QUEUE_TRAIN_CONFLICT: train <id> stopped at #<pr> (conflicts: <files>) after
merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
@@ -1222,7 +1227,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-scope"></a>
@@ -1524,7 +1534,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A base line whose text duplicates a line the branch added gets the branch's bound too, because the checks match branch lines by text, not position (GST-979). The /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior (GST-980).
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
+4 -1
View File
@@ -961,7 +961,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
+1 -1
View File
@@ -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-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. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). |
+4 -3
View File
@@ -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
+41 -8
View File
@@ -2592,12 +2592,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.
@@ -3855,7 +3867,10 @@ repository), `api_error` (gh could not read the run), `workflow_mismatch` (a
run of another workflow), `run_in_progress` (still queued or running) or
`run_not_success` (it failed, was cancelled or timed out). Each prints a fix
line; after the last three, clear again with a finished green run of the
lane's own workflow ([run evidence](pregate.md#run-evidence)).
lane's own workflow ([run evidence](pregate.md#run-evidence)). For a lane whose
workflow runs only through `workflow_call`, a run of a workflow whose job says
`uses: ./.github/workflows/<lane's>.yml` counts as a run of the lane (GST-971);
it refuses `caller_job_skipped` when its `<calling job> / <lane job>` job did not succeed.
Another tree is accepted in exactly one case: it differs from `pregate.json`'s
tree only in the repo's stamp rule (`.gstack/ship-policy.json` on the base
@@ -5486,6 +5501,23 @@ body no longer carries the exception's line word for word
/ship again and record the owner's acceptance with `--docs` or
`--review-loop-exception` ([ship-policy.md](ship-policy.md#ship-receipt)).
<a id="queue-receipt-targeted-missing"></a>
### `QUEUE_RECEIPT_TARGETED_MISSING`
**Meaning.** `ready`, `train build` or `land` recomputed the targeted
classes the head needs (money, gate, auth, secret) from git, and the receipt's
`review_targeted` does not cover every one of them with a `done` pass. The
`REVIEW_TARGETED:` line prints the required classes, the classes a done pass
covered (`legacy` when the receipt records no targeted pass) and
`verdict=missing`, or `verdict=failed` when the recorded pass failed. A
head that needs no class passes whatever the receipt records. The receipt is
unchanged.
**Fix.** Run /ship on the head the READY names so the targeted reader runs on
every required class and `gstack-ship-receipt write --review-targeted` records
a done pass ([review-policy.md](review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code)).
Never edit `review_targeted` by hand.
<a id="queue-screen-stale-ref"></a>
### `QUEUE_SCREEN_STALE_REF`
@@ -5883,7 +5915,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
+2 -2
View File
@@ -58,9 +58,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+7 -2
View File
@@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
@@ -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
+5
View File
@@ -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
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+9 -7
View File
@@ -37,6 +37,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { runBin } from './spawn-bin';
import { logHookError as sharedLogHookError } from './hook-log';
import { parseQidMarker } from '../../../scripts/qid-marker';
interface HookStdin {
session_id?: string;
@@ -65,7 +66,6 @@ interface ExtractedQuestion {
door_type?: string;
}
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
function logHookError(msg: string): void {
@@ -103,13 +103,14 @@ function extractQuestionId(
skill: string,
questionText: string,
options: string[],
): { id: string; marker_present: boolean; stripped_question: string } {
const match = questionText.match(MARKER_RE);
if (match) {
): { id: string; signal_key?: string; marker_present: boolean; stripped_question: string } {
const marker = parseQidMarker(questionText);
if (marker) {
return {
id: match[1],
id: marker.id,
...(marker.signal_key ? { signal_key: marker.signal_key } : {}),
marker_present: true,
stripped_question: questionText.replace(MARKER_RE, '').trim(),
stripped_question: marker.stripped,
};
}
return {
@@ -289,7 +290,7 @@ async function main(): Promise<void> {
if (!qText) continue;
const opts = optionLabels(q.options || []);
const { id, stripped_question } = extractQuestionId(skill, qText, opts);
const { id, signal_key, stripped_question } = extractQuestionId(skill, qText, opts);
const recommended = extractRecommended(stripped_question, opts);
const summary = stripped_question.slice(0, 200);
const choice = choices[i] || { choice: '__unknown__' };
@@ -305,6 +306,7 @@ async function main(): Promise<void> {
tool_use_id: stdin.tool_use_id?.slice(0, 128),
};
if (recommended) payload.recommended = recommended.slice(0, 64);
if (signal_key) payload.signal_key = signal_key;
if (choice.free_text) payload.free_text = String(choice.free_text);
spawnLog(payload, stdin.cwd);
@@ -51,6 +51,7 @@ import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv }
import { resolveStateRoot } from '../../../lib/state-root';
import { readVersionedSlugCache } from '../../../lib/bin-context';
import { logHookError as sharedLogHookError } from './hook-log';
import { QID_MARKER_RE as MARKER_RE } from '../../../scripts/qid-marker';
interface HookStdin {
session_id?: string;
@@ -67,7 +68,6 @@ interface HookStdin {
cwd?: string;
}
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
const stateRoot = (): string => resolveStateRoot();
+2 -2
View File
@@ -96,9 +96,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -53,9 +53,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -53,9 +53,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+21 -3
View File
@@ -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
View File
@@ -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;
}
+8
View File
@@ -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[];
+23 -4
View File
@@ -27,8 +27,9 @@ import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './confi
import { sha256, withFingerprint } from './context';
import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt';
import { resolveBaseBranch } from '../ship-policy';
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
import { displayNamePattern, workflowCallers } from './workflows';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
export const CHECK_IDS = CHECKS.map(c => c.id);
@@ -267,10 +268,28 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
try { run = input.evidence.deps.run(id); }
catch (e: any) { return invalid(`api_error — reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`, `check gh auth status and that gh can read ${repo}'s Actions runs, then rerun the same clear`); }
const want = `.github/workflows/${target.workflow}`;
if (!run || run.path !== want) return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}`, `clear ${lane} with a run of its own workflow: gh workflow run ${target.workflow} --ref <branch>, then gstack-pregate clear --remote ${lane} --run-url <that run's URL>`);
const { dispatch, jobName, callers } = workflowCallers(repoRoot, file.tree, target.workflow, target.job);
const runPath = run?.path;
const caller = callers.find(c => runPath === `.github/workflows/${c.file}`);
if (!run || (run.path !== want && !caller)) {
const names = callers.map(c => c.file).join(', ');
const dispatchable = dispatch ? [target.workflow] : callers.filter(c => c.dispatch).map(c => c.file);
const start = dispatchable.length ? dispatchable.map(f => `gh workflow run ${f} --ref <branch>`).join(' or ')
: callers.length ? `neither ${target.workflow} nor its callers have a workflow_dispatch trigger, so push a commit that starts ${callers.map(c => c.file).join(' or ')}`
: `${target.workflow} has no workflow_dispatch trigger, so push a commit that starts it`;
return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}${callers.length ? ` or a workflow that calls it (${names})` : ''}`, `clear ${lane} with a run of its own workflow${callers.length ? ` or of a workflow that calls it (${names})` : ''}: ${start}, then gstack-pregate clear --remote ${lane} --run-url <that run's URL>`);
}
const runOf = caller ?? { file: target.workflow, dispatch };
const green = `then gstack-pregate clear --remote ${lane} --run-url <the green run's URL>`;
if (run.status !== 'completed') return invalid(`run_in_progress — run ${id} attempt ${run.run_attempt} is ${run.status}`, `wait for run ${id} to finish green (or rerun ${target.workflow} if it fails), ${green}`);
if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${target.workflow} (gh run rerun ${id}, or gh workflow run ${target.workflow} --ref <branch>), ${green}`);
if (run.status !== 'completed') return invalid(`run_in_progress — run ${id} attempt ${run.run_attempt} is ${run.status}`, `wait for run ${id} to finish green (or rerun ${runOf.file} if it fails), ${green}`);
if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${runOf.file} (gh run rerun ${id}${runOf.dispatch ? `, or gh workflow run ${runOf.file} --ref <branch>` : ''}), ${green}`);
if (caller) {
let jobs: ApiJob[];
try { jobs = input.evidence.deps.jobs(id, run.run_attempt); }
catch (e: any) { return invalid(`api_error — reading run ${id}'s jobs failed: ${String(e?.message ?? e).slice(0, 300)}`, `check gh auth status and that gh can read ${repo}'s Actions runs, then rerun the same clear`); }
const named = caller.jobs.map(n => new RegExp(`^${displayNamePattern(n)}(?: \\([^)]*\\))? / ${displayNamePattern(jobName)}(?: \\([^)]*\\))?$`));
if (!jobs.some(j => j.conclusion === 'success' && named.some(re => re.test(j.name)))) return invalid(`caller_job_skipped — run ${id} attempt ${run.run_attempt} of ${caller.file} has no successful job named ${caller.jobs.map(n => `"${n} / ${jobName}"`).join(' or ')}, so ${target.workflow} did not run in it`, `clear with a ${caller.file} run in which that job runs${caller.dispatch ? ` (gh workflow run ${caller.file} --ref <branch>)` : ''}, ${green}`);
}
return { receipt: `https://github.com/${repo}/actions/runs/${id}/attempts/${run.run_attempt}`, attested: false };
}
const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined);
+49
View File
@@ -8,6 +8,7 @@
* runner-owned selection manifest `.gstack/pregate.json` declares for it, or
* it is reported as an unsupported expression and treated conservatively.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { laneOf, type Lane as Platform } from '../runtime-pins';
@@ -183,6 +184,54 @@ export function parseWorkflow(file: string, text: string, platform: NodeJS.Platf
});
}
/** A workflow's `on:` trigger names, from `on: x`, `on: [x, y]`, `on: { x: {}, y: {} }` or its keys and list items. */
function triggers(top: Block[]): string[] {
const on = top.find(b => /^['"]?on['"]?$/.test(b.key));
if (!on) return [];
return [...on.value.replace(/\s+#.*$/, '').split(/[\s,[\]{}:]+/), ...on.children.map(c => c.key)].map(s => s.trim()).filter(Boolean);
}
/** A `name:` or `uses:` value without its trailing comment and quotes. */
function scalar(value: string): string { return value.replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, ''); }
/**
* `workflow`'s workflow_dispatch trigger, the display name of its job `job`, and the workflow files with a job that calls it as
* a local reusable workflow (`uses: ./.github/workflows/<workflow>`), each with its own workflow_dispatch trigger and the display
* names of its calling jobs, all read from git `tree` (pregate.json's recorded tree), never the working tree. A workflow_call
* run reports its caller's file and its jobs as `<calling job> / <job>`, so a caller's run is a run of the lane (GST-971).
* Display names keep `${{ … }}` expressions as written; an unreadable tree or file has no trigger and no callers.
*/
export function workflowCallers(repoRoot: string, tree: string, workflow: string, job: string): { dispatch: boolean; jobName: string; callers: Array<{ file: string; dispatch: boolean; jobs: string[] }> } {
const git = (args: string[]) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
const ls = git(['ls-tree', '--name-only', `${tree}:.github/workflows`]);
const names = ls.status === 0 ? ls.stdout.split('\n').filter(n => /\.ya?ml$/.test(n)).sort() : [];
const want = `./.github/workflows/${workflow}`;
let dispatch = false;
let jobName = job;
const callers: Array<{ file: string; dispatch: boolean; jobs: string[] }> = [];
for (const n of names) {
const show = git(['show', `${tree}:.github/workflows/${n}`]);
if (show.status !== 0) continue;
const top = outline(show.stdout);
const jobs = find(top, 'jobs')?.children ?? [];
const canDispatch = triggers(top).includes('workflow_dispatch');
if (n === workflow) {
dispatch = canDispatch;
const own = find(jobs, job);
jobName = own ? scalar(find(own.children, 'name')?.value ?? '') || job : job;
continue;
}
const calling = jobs.filter(j => scalar(find(j.children, 'uses')?.value ?? '') === want).map(j => scalar(find(j.children, 'name')?.value ?? '') || j.key);
if (calling.length) callers.push({ file: n, dispatch: canDispatch, jobs: calling });
}
return { dispatch, jobName, callers };
}
/** A regex source matching a display name GitHub renders from `name`: each `${{ … }}` expression matches any text without ` / `. */
export function displayNamePattern(name: string): string {
return name.split(/\$\{\{.*?\}\}/).map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:(?! / ).)+?');
}
/** Every job of every workflow, in file order. */
export function discoverLanes(repoRoot: string, platform: NodeJS.Platform = process.platform): Lane[] {
const dir = path.join(repoRoot, '.github', 'workflows');
+63
View File
@@ -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);
}
+447
View File
@@ -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;
}
+7 -31
View File
@@ -362,36 +362,9 @@ export function stampMentions(text: string, templates: readonly string[] = []):
return found;
}
/**
* A removed/added line pair that a restamp alone explains: every version the
* removed line mentions in a stamp form and the added line no longer does
* moves to `newV`, and the rewritten line is the added line byte for byte.
* The moved versions are the ones the stamp_paths stage moves, not only the
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
* minor>'` never equals the base version at the slot and is a stamp all the
* same (#3512). The stage's own bound applies: a tentative mention is newer
* than the version the branch was written against, so a moved version older
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
* moves nothing, or changes anything beyond those mentions, is not one either.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean {
if (removed === added) return false;
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
const after = stampMentions(added, templates);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
if (!moved.length || !floor) return false;
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
let text = removed;
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
return text === added;
}
/** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */
export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string {
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim();
export function forkVersion(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): string {
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..${head}`]).out.split('\n')[0]?.trim();
if (!first) return fallback;
const v = versionAtRef(repoRoot, `${first}^`);
return 'version' in v ? v.version : fallback;
@@ -525,7 +498,9 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
/**
* The stamp_paths stage. An explicit old version (the branch's own stamp, or
* `--was`) is rewritten on every line of every stamp file; a tentative one
* (a mention newer than the fork version) only on lines the branch added.
* (a mention newer than the fork version, or of the fork version itself: a
* row written at the version the branch forked from instead of a next one,
* GST-963) only on lines the branch added.
* With no explicit version the branch's VERSION equals the base's: either it
* never stamped, or it stamped X and the base reached X on its own (a PR that
* lost its slot and merged the base). Then a stamp-form mention of the base
@@ -548,6 +523,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v)));
const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null;
if (equalBase) tentative.add(equalBase);
if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion);
const moved = new Map<string, number>();
const baseSide: string[] = [];
let touched = 0;
@@ -590,7 +566,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
const baseOwnNote = baseOwn.length ? `; ${baseOwn.reduce((n, [, own]) => n + own.notOwn, 0)} line(s) the base added since the fork point are the base's, not this branch's, and stay (${baseOwn.map(([rel, own]) => `${rel}: ${own.notOwn}`).join('; ')})` : '';
sp.detail = (olds.length
? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}`
: `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote;
: `${searched}no tentative mention at or newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote;
}
function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] {
+5
View File
@@ -264,6 +264,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR body’s /ship receipt records an owner exception (docs or review loop) that is malformed, lacks the fix-check it follows, or whose line the body no longer carries verbatim',
fix: 'restore the exception line in the PR body word for word, or run /ship again and record the owner’s acceptance with --docs or --review-loop-exception',
},
QUEUE_RECEIPT_TARGETED_MISSING: {
anchor: 'queue-receipt-targeted-missing',
summary: 'the head touches money, gate, auth or secret code that needs the targeted reader, and the PR body’s /ship receipt records no done targeted pass covering every required class (none recorded, failed, not run, or other classes)',
fix: 'run /ship on the head the READY names so the targeted reader covers every class the REVIEW_TARGETED line lists as required; the receipt is left unchanged',
},
QUEUE_SCREEN_STALE_REF: {
anchor: 'queue-screen-stale-ref',
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
+23 -3
View File
@@ -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];
@@ -338,6 +338,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 }
@@ -349,8 +355,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' };
@@ -475,7 +492,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;
@@ -535,8 +552,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);
}
+40 -10
View File
@@ -8,8 +8,8 @@
* judgment each lead-judgment change: an added or promoted finding, with its reason
* judge the verdict on the parent's judged set (ok, or the dropped and downgraded ids)
*
* Findings reach merged.jsonl only through collect, with each voice's label as written (a prose line's most severe one,
* raised by the Severity line below it), except that a prose voice's `Blocks: yes` and the native voice's FIXABLE collect
* Findings reach merged.jsonl only through collect, with each voice's label as written (a prose finding's own Severity field when
* it has one, else its line's most severe label), except that a prose voice's `Blocks: yes` and the native voice's FIXABLE collect
* as CRITICAL (GSTA-167 normalizer v2).
* The parent's judged set may add or promote a finding and may fold a duplicate into one at least
* as severe; it never drops or downgrades one. verify replays every check from the ledger alone.
@@ -67,6 +67,20 @@ const META = new RegExp(`^(?:[-*+]\\s+)?\\**\\s*(Severity|Blocks)\\**\\s*(?::|\\
/** The severity and Blocks a prose finding's own fields state (GST-766): recorded beside the collected label, never in its place. */
export interface Stated { severity?: string; blocks?: string }
const STATED_SEVERITY = new RegExp(`(?<![A-Za-z0-9])Severity\\**\\s*:?\\s*\\**\\s*(${LABELS})(?![A-Za-z0-9])`, 'gi');
/**
* A finding's own `Severity` field: at the line's start (its colon optional there, "Severity Low"), or after a separator with its colon
* ("1. Drops rows (q.ts:7). Severity: Low", "(t.md:3) Severity: Low", "- Severity: P0"), its label closing the clause. With one present a severity word in the body never raises the label (GST-976); prose that mentions a
* severity ("pass 1 had Severity: Low", "filed at severity low", "(Severity: Low in pass 1)") is not a field.
*/
const SEVERITY_FIELD = new RegExp(`(?:^(?:[-*+>]\\s+|\\d+[.)]\\s+)?\\**\\s*Severity\\**\\s*:?\\s*\\**\\s*((?:${LABELS})(?:\\s*[/→]\\s*\\**\\s*(?:${LABELS}))*)(?![A-Za-z0-9])|(?:[.;,|()]\\s*|\\s[—–-]\\s*)\\**\\s*Severity\\**\\s*:\\s*\\**\\s*((?:${LABELS})(?:\\s*[/→]\\s*\\**\\s*(?:${LABELS}))*)(?![A-Za-z0-9])(?=\\**\\s*(?:$|[.;,|()*—–]|-\\s|Blocks)))`, 'gi');
/**
* A label leading its line past list, quote, heading, emphasis and emoji marks (one mark per step, so a long divider stays linear)
* and an optional file:line, closed by a mark or a separator, or by a space, comma or parenthesis after an all-caps word ("[P0] …",
* "1. **High**: …", "### CRITICAL: …", "1. api.ts:10 CRITICAL: …", "1. CRITICAL missing auth"): the reader's own label, which a
* Severity field never lowers. "P0 pilot …" is not one.
*/
const FIELD_LABELS = new RegExp(ANY_LABEL.source, 'gi');
const LEAD_LABEL = new RegExp(`^(?:[^\\p{L}\\p{N}\\s]\\s*|\\d+[.)]\\s*)*(?:\\w[\\w./-]*:\\d+(?:[-–:,]\\s*\\d+)*[^\\p{L}\\p{N}\\s]*\\s+(?:[^\\p{L}\\p{N}\\s]\\s*)*)?(${LABELS})(?=[*_\`\\])]|\\s*[:|—–]|\\s+-\\s|\\s*$|(?<=[A-Z]{3})[\\s,(.])`, 'u');
const STATED_BLOCKS = /(?<![A-Za-z0-9])Blocks\**\s*:?\s*\**\s*(yes|no)(?![A-Za-z0-9])/gi;
function stated(raw: string): Stated | undefined {
if (raw.startsWith('{')) return undefined;
@@ -80,15 +94,17 @@ export interface MergedLine { id: string; reader: string; label: string; stated?
/**
* One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or
* FIXABLE/INVESTIGATE label, at the most severe label on it, and each top-level list item under a label-only heading (the stronger
* of the two labels). A `Severity` or `Blocks` field line continues the open finding, once per field: `Severity` raises its label,
* `Blocks: yes` makes it CRITICAL, `Blocks: no` never lowers it; with no finding open it is one, carrying the paragraph's subject.
* of the two labels). A line with a `Severity` field takes that field's label instead (a field stating more than one label, "High/Critical", counts as none), whatever its
* body names (GST-976: "the P0 pilot" collected as P0 under `Severity: Low`); a label leading the line and an earlier field of the same
* finding are its floor. A `Severity` or `Blocks` field line continues the open finding, once per field: `Severity` sets its label, `Blocks: yes` makes it CRITICAL, `Blocks: no` never lowers it; with no finding open it is one, carrying
* the paragraph's subject.
* A list item, a heading, a bold or post-blank paragraph at the margin, or a line naming a file:line after the open finding's fields
* starts the next finding, except an unlabeled, unnumbered bullet with no file:line under a paragraph finding whose fields have not
* come yet, which is its detail and joins its text (GSTA-196: 4 findings with detail bullets collected as 8). An inline
* `Blocks: yes` is CRITICAL too. The native voice's FIXABLE collects as CRITICAL; other labels stay as written for lead judgment. When in doubt the parser merges rather than drops: a lost blocker costs the gate.
*/
export function parseFindings(reader: string, output: string): Array<{ label: string; raw: string }> {
type Finding = { label: string; raw: string; fields: Set<string>; list: boolean };
type Finding = { label: string; raw: string; fields: Set<string>; list: boolean; lead?: string };
const out: Finding[] = [];
let section: string | null = null;
let current: Finding | null = null;
@@ -97,8 +113,17 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
let treeSeen = false;
const role = reader.split('/')[0];
const named = (l: string) => (role === 'adversarial-native' && l.toUpperCase() === 'FIXABLE' ? 'CRITICAL' : l);
const strongest = (t: string) => [...t.matchAll(ALL_LABELS)].map(m => named(m[1]!)).reduce<string | undefined>((a, b) => (a === undefined ? b : stronger(a, b)), undefined);
const push = (label: string, raw: string, fields: string[] = []): Finding => { const f = { label, raw, fields: new Set(fields), list: LIST_ITEM.test(raw) }; out.push(f); return f; };
const strongestOf = (labels: Array<string | undefined>) => labels.reduce<string | undefined>((a, b) => (a === undefined ? b : b === undefined ? a : stronger(a, b)), undefined);
const strongest = (t: string) => strongestOf([...t.matchAll(ALL_LABELS)].map(m => named(m[1]!)));
const fieldOf = (t: string) => { const ls = [...t.matchAll(SEVERITY_FIELD)].flatMap(m => (m[1] ?? m[2]!).match(FIELD_LABELS)!); return ls.length === 1 ? ls[0] : undefined; };
const critical = (t: string) => BLOCKS_YES.test(t) || (role === 'adversarial-native' && /(?<![A-Za-z0-9])FIXABLE(?![A-Za-z0-9])/.test(t));
const leadOf = (t: string) => { const l = LEAD_LABEL.exec(t)?.[1]; return l === undefined ? undefined : named(l); };
const labelOf = (t: string) => {
const field = fieldOf(t);
if (field === undefined) return strongest(t);
return critical(t) ? 'CRITICAL' : strongestOf([leadOf(t), field])!;
};
const push = (label: string, raw: string, fields: string[] = [], lead?: string): Finding => { const f = { label, raw, fields: new Set(fields), list: LIST_ITEM.test(raw), lead }; out.push(f); return f; };
for (const line of output.replace(/\r\n/g, '\n').split('\n')) {
const t = line.trim();
if (!t) { gap = true; continue; }
@@ -107,7 +132,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
gap = false;
if (/^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) { current = null; continue; }
const indented = line !== line.trimStart();
const label = strongest(t);
const label = labelOf(t);
const blocks = BLOCKS_YES.test(t);
const listItem = !indented && LIST_ITEM.test(t);
const locus = LOCUS.test(t);
@@ -117,6 +142,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
if (current && !current.fields.has(field)) {
current.fields.add(field);
if (blocks) current.label = 'CRITICAL';
else if (field === 'severity' && fieldOf(t) !== undefined && !critical(current.raw)) current.label = strongestOf([current.lead, label])!;
else if (label) current.label = stronger(current.label, label);
current.raw += ` | ${t}`;
} else if (blocks || label) current = push(blocks ? 'CRITICAL' : label!, head ? `${head} | ${t}` : t, [field]);
@@ -129,7 +155,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
if (t.startsWith('{')) {
try {
const o = JSON.parse(t) as Record<string, unknown>;
if (typeof o.severity === 'string') { current = push(o.severity, t); continue; }
if (typeof o.severity === 'string') { current = push(o.severity, t, [], o.severity); continue; }
} catch { /* prose that starts with a brace */ }
}
const detail = listItem && /^[-*+]\s/.test(t) && !locus && !label && !section && !!current && !current.list && !current.fields.size;
@@ -138,7 +164,11 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
if (fresh) { current = null; head = t; }
const inherited = section && listItem ? section : undefined;
const found = label && inherited ? stronger(inherited, label) : label ?? inherited;
if (found) { current = push(blocks ? 'CRITICAL' : found, t); if (!indented) head = t; continue; }
if (found) {
current = push(blocks ? 'CRITICAL' : found, t, [], strongestOf([leadOf(t), inherited, fieldOf(t)]));
if (!indented) head = t;
continue;
}
if (blocks && !t.startsWith('{')) {
if (current) { current.label = 'CRITICAL'; current.raw += ` | ${t}`; continue; }
current = push('CRITICAL', head && head !== t ? `${head} | ${t}` : t);
+6 -22
View File
@@ -10,7 +10,8 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp';
import { parseUnifiedDiff, versionAtRef } from './restamp';
import { branchSides, stampOnlyLine } from './stamp-only';
import { matchesAny } from './ship-policy';
import { extractVersion } from './version-source';
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
@@ -109,8 +110,8 @@ export function releasePaths(policy: Record<string, any> | null, cwd: string, re
* old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile).
* A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule:
* added since the merge base with origin/<base>, and not added by the base since): after a lost slot and a merge of the
* base its stale stamp is older than `ref`'s VERSION, so it must be newer than the fork point's version instead, the
* bound gstack-restamp's stamp_paths stage uses (GST-895).
* base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead,
* the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963).
*/
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : [];
@@ -123,25 +124,8 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
if (diff.status !== 0) return [];
let fork: string | undefined;
let since: string | undefined;
const own = new Map<string, Set<string>>();
const ownLines = (rel: string): Set<string> => {
if (own.has(rel)) return own.get(rel)!;
since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim();
let lines = new Set<string>();
if (since) {
try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {}
}
own.set(rel, lines);
return lines;
};
const stamp = (rel: string, removed: string, added: string): boolean => {
if (stampOnlyLine(removed, added, before.version, after, templates)) return true;
if (!ownLines(rel).has(removed)) return false;
fork ??= forkVersion(cwd, `origin/${base}`, before.version);
return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork));
};
const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref);
const stamp = (rel: string, removed: string, added: string): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel));
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path);
}
+1 -1
View File
@@ -339,7 +339,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`];
git(root, ['fetch', '-q', 'origin', ctx.base]);
const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]);
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []));
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : []));
if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run);
if (!policy.changelog_fragments) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'the base policy has changelog_fragments off; land merges only under the key, where ready records the READY receipt it checks' }, run);
const keys = repoIdentity(root).keys;
+1 -1
View File
@@ -42,7 +42,7 @@ export function isReleasePath(root: string, policy: ShipPolicy, versionRel: stri
* byte for byte, and equals T_i outside release paths. Null when all hold, else why not.
*/
export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null {
const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel });
const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1` });
if ('error' in r) return r.error;
if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`;
const cl = o.policy.changelog;
+3 -3
View File
@@ -34,7 +34,7 @@ import { genericBackstop } from './ship-queue-train-gate';
const Q = 'gstack-ship-queue';
function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same: boolean; detail: string } {
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label });
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${gated}^1` });
if ('error' in r) return { same: false, detail: r.error };
return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` };
}
@@ -79,7 +79,7 @@ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main:
const after = t.prefixes[i + 1]!;
const root = env.ctx.repoRoot;
if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` };
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label });
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` });
if ('error' in r) return { same: false, detail: r.error };
const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`;
if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` };
@@ -105,7 +105,7 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa
const post = sameModuloStamps(env, after.commit, main);
if (post.same) return post;
const cl = env.policy.policy.changelog;
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label });
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` });
if ('error' in r || !cl || r.residual.some(f => f !== cl)) return post;
const parent = git(env.ctx.repoRoot, ['rev-parse', '--verify', '-q', `${main}^`]).out;
if (!parent || !sameModuloStamps(env, t.prefixes[i]!.commit, parent).same) return { same: false, detail: `${post.detail}; main's parent ${parent.slice(0, 12) || '(none)'} is not T${t.prefixes[i]!.index} modulo stamps` };
+1 -1
View File
@@ -367,7 +367,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry
}
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
const shipped = bindTrainReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid], !!ready?.base_red);
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : []), ...(shipped.targetedLine ? [`${shipped.targetedLine} pr=${p.pr}`] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
lines.push(`${shipped.targetedLine} pr=${p.pr}`);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
let screen = ready.screen;
+1 -1
View File
@@ -66,7 +66,7 @@ export function receiptDigest(r: TreeReceipt): string {
}
function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure {
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label });
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label, baseRef: `${ctx.remote ?? 'origin'}/${ctx.base}` });
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
}
+23 -13
View File
@@ -13,8 +13,8 @@
* The queue recomputes the depth the head needs from git objects
* (lib/review-collect.ts) and refuses a receipt below it.
* `review_targeted`: the targeted pass on money, gate, auth and secret code
* (lib/review-targeted.ts), which the queue reports beside the depth and does
* not refuse on yet (GST-257 PR 1). Kept beside
* (lib/review-targeted.ts); the queue reports it beside the depth and refuses
* a head whose required classes the pass did not cover (GST-966). Kept beside
* lib/ship-receipt.ts so that module stays under the size ratchet.
*/
import { spawnSync } from 'node:child_process';
@@ -146,16 +146,25 @@ export function parseReviewTargeted(value: unknown, productPatchId: string): { o
return { ok: true, targeted: { classes, rules: v.rules as string, brief_sha: v.brief_sha, product_patch_id: productPatchId, status: v.status as ReviewTargeted['status'], findings: v.findings, raised_by: (v.raised_by as string).trim() } };
}
/** The queue's report-only line: the classes the head needs against the classes a done targeted pass covered; never a refusal (GST-257 PR 1). */
export function receiptTargetedLine(required: readonly TargetedClass[], r: ShipReceipt): string {
export interface DepthVerdict { ok: boolean; line: string; message?: string }
/**
* The classes the head needs against the classes a done targeted pass covered; a required class left uncovered (no pass,
* a failed or unrun pass, or a pass on other classes) is refused (GST-966). A receipt with neither gstack_version nor
* review_depth predates targeted receipts, as receiptDepthVerdict treats it, and is accepted as legacy.
*/
export function receiptTargetedVerdict(required: readonly TargetedClass[], r: ShipReceipt): DepthVerdict {
const rt = r.review_targeted;
const covered: readonly string[] = rt?.status === 'done' ? rt.classes : [];
const ok = required.every(c => covered.includes(c));
return `REVIEW_TARGETED: required=${classList(required)} receipt=${rt ? classList(covered) : 'legacy'} verdict=${ok ? 'ok' : 'missing'}`;
const missing = required.filter(c => !covered.includes(c));
const verdict = !missing.length ? 'ok' : rt?.status === 'failed' ? 'failed' : 'missing';
const line = `REVIEW_TARGETED: required=${classList(required)} receipt=${rt ? classList(covered) : 'legacy'} verdict=${verdict}`;
if (!missing.length) return { ok: true, line };
if (!rt && !r.review_depth && !r.gstack_version) return { ok: true, line: `${line} (legacy receipt, written before receipts recorded review_targeted; accepted)` };
const why = !rt ? 'records no targeted pass' : rt.status === 'failed' ? 'records that its targeted pass failed' : rt.status === 'not-run' ? 'records that its targeted pass did not run' : `covers ${classList(covered)} only`;
return { ok: false, line, message: `the head requires the targeted reader on ${missing.join(', ')}, but the receipt ${why}` };
}
export interface DepthVerdict { ok: boolean; line: string; message?: string }
/** The head's committed range against the base, read once for the depth verdict and the targeted line. */
interface HeadInputs { baseSha: { ok: boolean; out: string }; c: ReturnType<typeof collectCommit>; scope: Record<string, string> }
function headInputs(root: string, baseRef: string, head: string): HeadInputs {
@@ -192,10 +201,10 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip
return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` };
}
/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. */
/** bindReceipt plus the owner exceptions, the review depth and the targeted pass: what READY, train build and land accept. */
export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]):
| { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string }
| { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } {
| { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID' | 'QUEUE_RECEIPT_TARGETED_MISSING'; message: string; line: string; depthLine?: string; targetedLine?: string } {
const b = bindReceipt(body, pr, accepted);
if (!b.ok) return b;
const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok;
@@ -204,8 +213,9 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string,
const at = headInputs(root, baseRef, b.head);
const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head, at);
const gateTagged = boundScan(b.receipt, b.head) ?? (gateScanNeeded(at.c.paths, at.c.root) ? null : 0);
const targetedLine = receiptTargetedLine(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt);
if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line, targetedLine };
return { ...b, depthLine: d.line, targetedLine };
const t = receiptTargetedVerdict(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt);
if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line, targetedLine: t.line };
if (!t.ok) return { ok: false, code: 'QUEUE_RECEIPT_TARGETED_MISSING', message: `${t.message}; the receipt is unchanged; run /ship on ${b.head.slice(0, 12)}`, line: b.line, depthLine: d.line, targetedLine: t.line };
return { ...b, depthLine: d.line, targetedLine: t.line };
}
+18 -2
View File
@@ -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)!;
+5 -4
View File
@@ -12,7 +12,7 @@ import { FRAGMENT_README } from './changelog-fragments';
import { renderIssueLines } from './issue-links';
import { matchesAny } from './ship-policy';
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
import { stampOnlyLine } from './restamp';
import { stampOnlyLine, type BranchSide } from './stamp-only';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
@@ -188,13 +188,14 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
* `package.json`) and the base policy's `stamp_patterns` templates.
*/
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] }
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide }
/**
* A policy-named stamp path's change is a stamp when the new text reads as the
* old text once every new version in it is the old one, or when every changed
* line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512, and
* on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule
* the tree receipt applies to the same `stamp_paths`, `stamp_files` and
* `release_outputs` before the train accepts a stamp commit. No version
* change, or a file missing on either side, is not a stamp.
@@ -209,7 +210,7 @@ export function stampOnlyText(change: CarryChange): boolean {
const now = after.split('\n');
if (was.length !== now.length) return false;
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? []));
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side));
}
/**
+70
View File
@@ -0,0 +1,70 @@
/**
* Stamp-only checks: whether a removed/added line pair is a restamp alone, and
* the branch side (its own lines and fork version) that bounds that check for
* lines the branch wrote itself. Split from restamp.ts (module-size ratchet).
*/
import { spawnSync } from 'node:child_process';
import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp';
import { cmpVersion, parseVersion, versionWidth } from './version-source';
/**
* A removed/added line pair that a restamp alone explains: every version the
* removed line mentions in a stamp form and the added line no longer does
* moves to `newV`, and the rewritten line is the added line byte for byte.
* The moved versions are the ones the stamp_paths stage moves, not only the
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
* minor>'` never equals the base version at the slot and is a stamp all the
* same (#3512). The stage's own bound applies: a tentative mention is newer
* than the version the branch was written against, so a moved version older
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
* moves nothing, or changes anything beyond those mentions, is not one either.
* With `side`, a removed line the branch added itself is bounded by its fork
* point's version instead, the stage's bound for its own lines (GST-895,
* GST-963): finish's stampedFiles, the tree receipt and the receipt carry all
* pass the same side.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean {
if (removed === added) return false;
return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates));
}
/** Lines the branch added to one file (since its merge base with the base, less the lines the base added since) and its fork point's version. */
export interface BranchSide { fork: string; added: ReadonlySet<string> }
/**
* Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to`
* (`head` unless a reviewed tree stands in for it), the fork version from `head`'s first-parent history
* (`fallback` when it has no own commit). A file git cannot diff has no side, so its lines keep the `oldV` bound.
*/
export function branchSides(repoRoot: string, baseRef: string, head: string, fallback: string, to = head): (rel: string) => BranchSide | undefined {
const sides = new Map<string, BranchSide | undefined>();
let since: string | undefined;
let fork: string | undefined;
return rel => {
if (sides.has(rel)) return sides.get(rel);
since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim();
let side: BranchSide | undefined;
if (since) {
try {
const added = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel)?.text;
if (added?.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added };
} catch {}
}
sides.set(rel, side);
return side;
};
}
function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean {
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
const after = stampMentions(added, templates);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
if (!moved.length || !floor) return false;
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
let text = removed;
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
return text === added;
}
+15 -11
View File
@@ -16,7 +16,8 @@ import * as path from 'node:path';
import { normalizeChangelog } from './changelog-check';
import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments';
import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle';
import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import { parseUnifiedDiff, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import { branchSides, stampOnlyLine, type BranchSide } from './stamp-only';
import type { ResultCodeName } from './result-codes';
import { matchesAny, type ShipPolicy } from './ship-policy';
@@ -55,7 +56,7 @@ function blob(cwd: string, treeish: string, rel: string): string | null {
* between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new
* heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree.
*/
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] {
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, side?: (rel: string) => BranchSide | undefined): FileVerdict[] {
const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null;
const folded = new Map<string, Fragment>();
for (const f of fileDiffs) {
@@ -65,11 +66,11 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
if (parsed && !('code' in parsed)) folded.set(f.path, parsed);
}
const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments);
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments, side);
const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog);
const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp';
return fileDiffs.map((f): FileVerdict => {
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV);
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, [], side);
return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` };
});
}
@@ -77,15 +78,15 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
/**
* Every removed/added pair differs only by stamp rewrites to the new version:
* the old version, or a tentative one (newer than it) the stamp_paths stage
* moved (#3512), each in a stamp form (`stampOnlyLine`).
* moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines).
*/
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null {
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null {
for (const h of hunks) {
if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at };
for (let i = 0; i < h.removed.length; i++) {
const r = h.removed[i]!;
const a = h.added[i]!;
if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
if (!stampOnlyLine(r, a, oldV, newV, templates, side)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
}
}
return null;
@@ -96,7 +97,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string
return normalizeLockfile(name, a) === normalizeLockfile(name, b);
}
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict {
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = [], side?: (rel: string) => BranchSide | undefined): FileVerdict {
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
if (f.binary) return changed('binary', 'binary content differs');
@@ -127,8 +128,9 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)');
}
const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null;
if (oldV && newV && oldV !== newV && rule) {
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns);
const own = rule ? side?.(f.path) : undefined;
if (oldV && newV && (oldV !== newV || own) && rule) {
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own);
return bad ? changed(rule, bad.why, bad.line) : stamp(rule);
}
return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`);
@@ -136,6 +138,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
export interface ReceiptOptions {
repoRoot: string; gated: string; current?: string; policy: ShipPolicy; policyLabel: string; versionRel?: string;
/** The base ref the gated head's own lines are read against (the fork-version bound, GST-963); without it every line keeps the old version's bound. */
baseRef?: string;
predecessor?: { pr: string | null; recordedHead: string | null; ref?: string | null; currentHead?: string | null };
bundlePath?: string; gh?: (args: string[]) => { ok: boolean; out: string }; env?: NodeJS.ProcessEnv; rerunCommand?: string;
}
@@ -176,7 +180,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' };
}
const diffText = d.stdout.replace(/\n$/, '');
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined);
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : '';
+2 -2
View File
@@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -99,9 +99,9 @@ Q-numbering starts at `Q1` per invocation, independent of D-numbering. Questions
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.93.0",
"version": "1.93.11",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
@@ -103,6 +103,7 @@
"typescript": "7.0.2"
},
"overrides": {
"@hono/node-server": "2.1.6",
"@modelcontextprotocol/sdk": "1.32.0",
"adm-zip": "^0.6.1",
"fast-uri": "3.1.9",
@@ -110,6 +111,7 @@
"image-size": "2.0.4",
"ip-address": "10.7.4",
"proxy-addr": "2.0.8",
"qs": "6.16.2",
"sharp": "0.35.5"
},
"patchedDependencies": {
+2 -2
View File
@@ -51,9 +51,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -98,9 +98,9 @@ Before emitting a tool or prose decision brief, verify:
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -104,9 +104,9 @@ Before emitting a tool or prose decision brief, verify:
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -68,9 +68,9 @@ Before emitting a tool or prose decision brief, verify:
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -189,9 +189,9 @@ Before emitting a tool or prose decision brief, verify:
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -58,9 +58,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -72,9 +72,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+2 -2
View File
@@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
+10 -9
View File
@@ -13,41 +13,42 @@
"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":"f14ca0db6504480698acd662351b6f0d6ef31a7b","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"},
"test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":6},
"test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6},
"test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6},
"test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":6},
"test/helpers/pty/boundaries.ts": {"blob":"c5520118da36f96f60a3c9720751bb57a669865c","since":6,"nonBehavioral":"Widens the plan-eng-review first-review severity annotation from [P0-3] to [P0-3|CRITICAL|INFORMATIONAL] (#3349). Only a question carrying the new labels can change verdict, and only the changed plan-eng-review prompt asks for them; every case that runs it owns that generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."},
"test/helpers/pty/boundaries.ts": {"blob":"c09b69611fb22ddf093adb2baa83434494e5f8fa","since":6,"nonBehavioral":"Widens the two plan-eng-review boundary predicates' qid regex to also accept the optional ;signal=<key> hint suffix (#3442). Only a question carrying that suffix can change verdict, and only the changed question-tuning resolver asks for it; every case that runs a skill with that resolver owns its generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."},
"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":"8a654dae6b0a396d839b02179119ae3e3bb13f69","since":6,"nonBehavioral":"extractSkillBody and extractSkillSections follow the shared-preamble hoist (pointer plus sections/preamble.md); every fixture they build is byte-identical to the pre-hoist output (compared for 8 body and 4 section extractions), so no case's input or verdict changes; the /ship review then reworded only comments and two error messages, so no fixture byte changes"},
"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)"},
+33 -8
View File
@@ -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');
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more