Merge main bf2cab34 into gsta258-l4-ready-receipts (rule 19 grant)

#3920 (GSTA-258 build C) conflicts, resolved mechanically:
- bin/gstack-ship-receipt: keep this branch's ship-recheck import beside main's
  depth import (now with platformReceiptDepth) and review-risk import; the help
  keeps --no-stop beside main's --declare-risk and --platform-review.
- review-army pin: both sides repinned ship/sections/review-army.md, which merged
  cleanly; repin it to the merged bytes under a combined GSTA-258-C+GSTA-258-L4
  replay (pin-file recipe, GSTA-202).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 19:19:42 +00:00
commit 1b31842a2d
127 files changed
+4487 -457

No files matched your search

+10
View File
@@ -4,6 +4,7 @@
"remote_gate": "bash scripts/ubicloud/pregate-remote.sh",
"dependencies": {
".github/workflows/**": ["test/pregate-selection.test.ts", "test/free-tests-workflow-wiring.test.ts"],
"patches/**": ["browse/test/playwright-core-patch.test.ts"],
"scripts/gen-ship-goldens.ts": ["test/host-config.test.ts"],
"bun.lock": [],
".gitignore": [],
@@ -31,5 +32,14 @@
"bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" }
},
"warn": [],
"guards": [
{ "id": "typecheck", "when": ["**/*.ts", "tsconfig.json", "package.json", "bun.lock"], "command": "bun run typecheck", "fix": "fix the type errors bun run typecheck prints" },
{ "id": "skill-size", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/skill-md-size-guard.test.ts", "test/skill-size-budget.test.ts", "test/catalog-budget.test.ts", "test/fixtures/skill-md-size-allowlist.json", "test/fixtures/parity-baseline-*.json", "test/helpers/skill-census.ts", "test/helpers/skill-startup.ts"], "command": "bun test test/skill-md-size-guard.test.ts test/skill-size-budget.test.ts test/catalog-budget.test.ts", "fix": "shorten the skill text the failure names; move a pin only when the growth is wanted, with the reason in the PR body" },
{ "id": "parity-suite", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/parity-suite.test.ts", "test/fixtures/parity-baseline-*.json", "test/helpers/parity-harness.ts"], "command": "bun test test/parity-suite.test.ts", "fix": "restore the missing phrase or heading, or shorten the skill back under its size ratio" },
{ "id": "context-budget", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/context-budget-ratchet.test.ts", "test/fixtures/context-budget*.json"], "command": "bun test test/context-budget-ratchet.test.ts", "fix": "shorten the skill under its token ceiling, or lower the ceiling when the skill shrank" },
{ "id": "module-size", "when": ["lib/**/*.ts", "bin/**", "scripts/**/*.ts", "browse/src/**/*.ts", "test/helpers/**/*.ts", "test/module-size-ratchet.test.ts", "test/fixtures/module-size-ratchet.json"], "command": "bun test test/module-size-ratchet.test.ts", "fix": "split the module or function the failure names; it may not grow past its recorded size" },
{ "id": "goldens", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "ship/**", "scripts/gen-ship-goldens.ts", "test/host-config.test.ts", "test/fixtures/golden/**"], "command": "bun test test/host-config.test.ts", "fix": "when the render change is intended, run bun scripts/gen-ship-goldens.ts and commit the updated goldens" },
{ "id": "doc-caps", "when": ["CLAUDE.md", "test/claude-md-size.test.ts"], "command": "bun test test/claude-md-size.test.ts", "fix": "move contributor-only detail from CLAUDE.md into docs/ and leave a one-line pointer" }
],
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
+1 -1
View File
@@ -1380,7 +1380,7 @@ browser's trust store and updates.
eight eMudhra intermediate CAs by CRLSet on 2026-10-17, the day their key
compromise was reported, and scheduled the removal of their root, emSign ECC
Root CA - G3, from the Chrome Root Store for 2026-10-27. The root store built
into gstack's pinned Playwright 1.62.1 (Chromium 151) still trusts that root, so
into gstack's pinned Playwright 1.64.1 (Chromium 156) still trusts that root, so
the headless browser accepts certificates those intermediates issued. That lasts
until gstack moves to the first Playwright release whose Chromium is
158.0.8115.0 or later, the first Chrome for Testing build measured to refuse
+110
View File
@@ -1,5 +1,115 @@
# Changelog
## [1.94.34.0] - 2026-11-03
### Fixed
- **The autoplan-guard-pty eval no longer fails when its test actor mistypes a hash.** The live guard test has a Haiku model act out an /autoplan phase boundary, and it had to copy each reviewer dispatch, including two 64-character hashes, into its Agent call. Every so often it got a hash wrong (in PR screen 41247097050 it made up a tail for the Design dispatch's hash), and the guard rightly refused the dispatch. Now the actor sends a short declared token, `DISPATCH ceo` or `DISPATCH design`, and a loopback relay in the test driver puts that phase's exact dispatch in place of the token before Claude Code sees the tool call. The relay also puts the token back in the history the actor sees, because in the first panel's trial 1 the actor copied the substituted CEO dispatch for its Design call. The guard, the hash in the dispatch, the Haiku actor and every assertion are unchanged. The first preregistered panel stopped at that trial 1 failure. After the history fix, the second panel passed 3 of 3 (4 of 4 guarded allows in each trial, no permission cards).
- **The plan-design-with-ui-scope eval accepts both ways the skill writes the outside-voices menu.** The question format says option descriptions use literal ✅/❌ bullets, and models read that two ways: earlier captures kept the bullets in the descriptions, while train gate 41470212877 moved them into the question text and dropped them from the descriptions, so the test's fixture user refused the menu. The fixture now declares both spellings, and the picker accepts exactly the declared description, or that description with every bullet removed. The red question is saved as a fixture. Restoring only some bullets, flipping one, swapping or rewording a description still fail. A focused paid run passed.
## [1.94.33.0] - 2026-11-03
Follow-ups to the skill routing and contract hooks (GST-1088). `gstack-ship-queue ready` no longer misreads files a PR adds as deleted when the installed gstack is older than the checkout: the paid-case selector now derives dependencies from the checkout under review. Each of the two hook switches now has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts` (both still off), which setup, both hooks, `gstack-config get|list|defaults` and the new `gstack-skill-route --default <switch>` all read, so changing a default is a one-line edit; `gstack-config list` and `defaults` now show both switches, an unrecognized value takes the default in the hooks as it already did in setup, and a config file that exists but cannot be read turns both hooks off in setup and in the hooks whatever the default. The contract checker no longer counts installs, builds or version probes (`bun install`, `go build`, `node --version`, `make clean`) as a test run after an edit, while option values (`make -C web test`, `pnpm --filter app test`) and `node --test` still count, and the Stop hook lets a final stop through while Agent dispatches are still running, recording the run as `deferred`, then checks the contract once the same agents have been running for 10 minutes; `gstack-skill-contract report` re-checks deferred runs.
## [1.94.32.0] - 2026-11-03
### Fixed
- **A RED paid screen now names its rule cases.** `gstack-ship-queue ready`, `verify --screen` and `train build` took a screen's red cases only from its behavior and judge panels, so rule cases that failed twice under the flaky-rerun rule never showed up. #3947's screen run 41662815506 printed `SCREEN_VERDICT: … RED flaky=ship-docsync-stale-after` with no `red=` list, and the refusal said "RED on its gate binding". That made it look like the one flaky case had turned the run red, when seven rule cases had actually failed twice. The verdict line and the `QUEUE_SCREEN_RED` refusal now list every case the report marks with ✗ (qa-quick, browse-basic and five more in that run). The verdict itself doesn't change. A case that passes on its one rerun is still green with a FLAKY line, and a case that fails twice is still red.
### Tests
- `test/ship-queue-screen-verdict.test.ts` reads the real gate binding and collector outcomes from run 41662815506. The seven twice-failed rule cases are named red, and the rerun-pass case stays only flaky. Negative control: with just the rerun-pass case left, the run reads GREEN with its FLAKY line. A red that never reran (INFRA, an unreported shard) is still named, and quarantined or passing split panel lines are not.
## [1.94.31.0] - 2026-11-03
**gstack's browser now runs Chromium 156, which fixes CVE-2026-106378, a macOS sandbox escape ([#3907](https://github.com/garrytan/gstack/issues/3907)).** Playwright 1.62.1 bundled Chromium 151, which predates the fix. Your next `./setup` or `/gstack-upgrade` downloads the new browser.
### Fixed
- **Playwright 1.64.1** (Chromium 156.0.8078.4, revision 1248). Chromium's fix is commit `0c2f3e54b265` ("Disclaim TCC responsibility for GPU process", crbug.com/507596239). Its code is in the tagged source for 156.0.8078.4 and missing from 151.0.7922.34. We checked the source; nobody ran the exploit. A new test fails if the bundled Chromium ever drops below 155.0.8059.12, the first tag with the fix.
- **`browse handoff` opens the browser `GSTACK_CHROMIUM_PATH` names**, as the headless and headed launches already did. Before, handoff always fell back to Playwright's bundled Chromium. It never probes or self-heals a custom browser, and it doesn't load the sidebar extension into a build that already bakes it in.
### For contributors
- `patches/playwright-core@1.64.1.patch` keeps the launch `windowsHide` hunk and the `TcpPortRandomizationWin` backport. Playwright 1.64 passes `windowsHide` on its `taskkill` call itself, so that hunk is gone; the installed-tree test still checks it.
- `.gstack/pregate.json` maps `patches/**` to `browse/test/playwright-core-patch.test.ts`, so the next Playwright bump gets a test lane without renaming anything.
- Linux arm64 now downloads the Chrome for Testing build (`chrome-linux-arm64.zip`).
## [1.94.30.0] - 2026-11-03
**/ship sends more diffs to full review: spend-binding code, eval scorers, a repo's own risk paths and docs that print secrets now count as risk, and a lane can declare a class the matchers miss (GSTA-258 build C).** Today's reviewer panel stays the default. The one-reader shape for non-risk code ships as an opt-in (`review.panel risk`) because its no-loss replay didn't pass: with only the diff and no repository, the reader found 2 of 68 non-risk blockers, so the replay is being redone with readers working in a checkout. A repo with platform review on can skip /ship's readers on plain docs.
### Added
- **A `review` key in `.gstack/ship-policy.json`**, read from the base branch only (needs `min_gstack_version` at the release that adds it): `panel` (`all` by default, today's panel; `risk` or `off` opt in to one reader), `reader_model` (`lane` by default), `platform` (`off` by default) and `risk_paths` (globs of code paths that count as risk class `policy`).
- **`gstack-review-plan` prints `REVIEW_MODE:`** and, under `review.platform on`, `PLATFORM_REVIEW:`. `DISPATCH:` lines name the reader's `model=` and `classes=`. New flags: `--fix-checks <n>` and `--pr <n>`.
- **The platform depth.** Under `review.platform on`, a docs or tests diff with no numeric, results or prereg claims, whose PR carries capy-ai[bot]'s review at the exact head, plans `NEXT: platform` with a planned `SKIP:` for every reader. Step 11.5 accepts it, and `gstack-ship-receipt write --platform-review <id>` records `review_depth: platform` with that review's id and head. It never applies to runtime code, and never where platform review is off.
- **New risk classes**: `eval` (scorer, judge and grader paths) and `policy` (`review.risk_paths`). A docs line that can print a credential (an `Authorization:` header, a bearer value, a key-shaped literal, `mcp get`) is security risk (gbrain#8417).
- **The receipt records `review_depth.mode`** (lean, panel or platform).
- **Declared risk classes** (GST-1129). `gstack-review-plan --declare-risk <classes>` and `gstack-ship-receipt write --declare-risk <classes>` add classes the matchers missed. A declaration only raises the depth, the plan prints `RISK_DECLARED:`, and the receipt keeps `review_depth.declared` beside `review_depth.detected`.
- **Code that reserves, binds, leases or settles spend is money** (GST-1129): `bindCheck(ledger, …)`, `reserveSpend` or `bindCap` in a code file now plans full depth. gstack-evals#170 got its full review only because an unrelated `UBICLOUD_API_KEY` line was in the diff.
### Changed
- **Opt-in one-reader review** (`review.panel risk`): non-risk code gets one native adversarial reader at the lane's reasoning, carrying the code checklists and the Red Team checklist, and risk class keeps the full panel. One fix-check follows a fixed blocker. It isn't the default until its no-loss replay passes.
- **Fewer false risk triggers** (harness-evals#231): committed `*.sqlite` and other binary data files count as data, never code; JSON, YAML and other config files are judged by their path, so a `lease_usd` value no longer means money; analysis scripts under `docs/`, `results/` or `benchmarks/` are judged by their path.
- The outside voice reads its diff from a file the brief names with its sha256, instead of a diff pasted into the prompt. Past 2,000,000 bytes it reads a path-scoped slice whose brief lists the files it left out, instead of stopping the pass.
## [1.94.29.0] - 2026-11-03
**Every part of gstack now has to earn its place.** Garry, 2026-11-02: "Also work this principle of only include it if it is beneficial and good into gstack". A new skill, step, instruction, check or default needs evidence that it helps on frontier models, or a reason, an owner and a dated plan to measure it. The first inventory reads untested almost everywhere: one part, `/investigate`, has a fair no-effect result (a CUT-CANDIDATE, measured on Opus 5.5 only), 3 have a fair test under way, 14 more have one proposed, and 5 safety guards stay on their stated reason.
### Added
- **ETHOS.md principle 9, Earn Its Place.** A fair test is Claude Code's own `claude_code` system prompt, frontier models (today Opus 5.5 and GPT-6.1 Sol) and a masked no-gstack arm. A part a fair test shows doesn't help gets one "Why null?" cycle, then is kept, fixed or cut. Measure the biggest parts first. The Capy factory page cites it in item 8, still under its 3,300-byte budget.
- **`docs/inventory/evidence.json`, the evidence ledger, and `bin/gstack-inventory`.** One row per part: every skill folder plus seven shared parts (preamble, catalog, the two Capy files, the /ship review panel and docs audit, the /autoplan phases), 71 keys in all. Each status has its own required fields (`helps`, `no-effect` and `hurts` need sources, frontier models, the preset prompt, a scope and a masked no-gstack arm, and the last two an R48 verdict; a new `untested` part needs a reason, a concrete plan, an owner and a `measure_by` date; `safety` needs a reason). Rows present today are grandfathered, seeded from the gstack-evals evidence table with their sources, plans and earlier results that don't count yet; no part added later can claim that (`test/fixtures/inventory-grandfathered.json`). `gstack-inventory` writes `docs/inventory.md` (parts ranked by load per run, a "measure next" order, preamble tiers, paid dispatches per run for /ship and /autoplan quoted from their own docs); `--check` validates the ledger; `--json` prints it. It works on the git repo you run it in and never writes into a gstack install.
- **`gstack-skill-bill` prints `EVIDENCE:`**, the skill's ledger row, and `/write-skill`'s edit ladder step 2 is now the evidence rule, with an `Evidence:` commit line beside `Skill-Size:`.
- **`/autoplan`'s CEO phase lists each part a plan adds to gstack** with its evidence or owned, dated plan; a missing one is a finding.
### Changed
- **No-gstack arms must be masked.** `gstack-eval-plan prereg --check` now also requires `masking:`, `install:` and `audit:` in `## No-gstack arm` and refuses a blank or `none` value (`PREREG_NO_GSTACK_ARM`). On Capy, run agents inherit the instruction to load the user-drive gstack skill, so the arm hides the Capy drives and checks that no gstack is installed. An arm that read a gstack skill file is contaminated and gets zero credit (docs/TESTING_INTERNALS.md#no-gstack-arm).
### For contributors
- CONTRIBUTING.md "Adding a new skill" adds the ledger row; `test/inventory.test.ts` fails on a skill without a valid row.
## [1.94.28.0] - 2026-11-03
**`gstack-ship-preflight --timeout` reaches Step 5's pregate (GST-1196).** Preflight ran `gstack-pregate` with no way to pass `--timeout`, so on a repo whose `.gstack/pregate.json` sets no `timeout_s`, a test group longer than 600 s always stopped with `PREGATE_INCOMPLETE`. The only workaround was a separate `gstack-pregate --timeout 1800` before finish (gstack-evals #176 and #179).
### Fixed
- **`gstack-ship-preflight`:** `--timeout <s>` (whole seconds, 1 to 3600) is passed to Step 5's pregate as its `--timeout` and overrides the policy's `timeout_s`. When it isn't set, `timeout_s` applies, and the default is 600 s, as the help now says. A bad value exits with usage (2).
- **`docs/pregate.md`:** the `timeout_s` row says preflight now forwards `--timeout`.
### Tests
- `test/ship-preflight.test.ts`: `--timeout 2` bounds a 4 s test under a 600 s policy and `PREGATE_DETAIL` names `--timeout`; 0, 3601, 1.5 and x are usage errors.
## [1.94.27.0] - 2026-11-03
- Housekeeping checks now run before every push instead of after a CI round (GST-1138, ship-90-90 plan). `gstack-pregate --stage guards --allow-repo-commands` runs only generated-file freshness (`regen`) and the repo's `guards`, each only for the files the branch touched, and writes `.gstack/tmp/pregate-guards.json` so it never replaces the full run's record. `gstack-ship-release push` runs it before every push, early or final, prints `GUARDS:`, and stops with the guard's fix when one fails, pushing nothing. A pass already recorded at HEAD's exact tree, by push itself or by `finish`'s preflight, is reused instead of rerun. See docs/pregate.md#prepush.
- gstack declares its own guards in `.gstack/pregate.json`: typecheck, the skill size guards, the parity suite, the context-budget ratchet, the module-size ratchet, the /ship goldens and the CLAUDE.md size cap. gbrain had 64 of 93 red heads from checks like these, and #3908 went red on the context-budget ratchet. Every gstack at or above this repo's version floor already reads the `guards` key, so the floor does not move.
### Tests
- `test/pregate-guards.test.ts`: `--stage guards` runs only `regen` and `guards`, writes its own record and refuses `--only` and `--from-run`; gstack's guard list validates, names test files that exist and fires only on its own inputs.
- `test/ship-release.test.ts`: a failing guard stops the push with its fix and pushes nothing; a pass at the same tree is reused, and a new commit runs the guards again.
On main, all five new tests fail.
## [1.94.26.0] - 2026-11-03
- A lane whose command runs several `bun test --shard=i/N` processes now records the sum of the shards' counts, not the last shard's (GBEV-3, gbrain-evals#317: 3336 recorded against 6,095 run across 2 shards). The sum needs shards 1/N to N/N, in order, at the end of the log; any other set records the last summary as before, so a command that runs a suite twice, or reruns one shard, still reports its last run. The shard lines and count lines that scroll out of the 64 KiB output tail are kept for this, so a long shard run still sums (a real 2-shard, 2000-test bun run recorded 1000 before and 2000 after).
- docs/pregate.md#dispatch-carry warns that a `workflow_dispatch` run reports under the same job names on the head commit, so without strict branch protection a green dispatch run can hide a red merge-commit check. It recommends strict protection or distinct job names for dispatch runs, and notes that gstack's own merge trains test the merged tree.
- When a pull_request run tested a merge whose base moved after the run, `CI_CARRY: none (… tree_mismatch …)` now says so and prints the one-command fix: `gh workflow run <workflow> --ref <branch>, then gstack-ci-carry wait` (TRUT-7).
- docs/pregate.md#skipped-files names the order of record-ci's checks after a skipped-file remainder, including the final `tree_changed` recheck.
### Tests
- `test/ship-ci-carry.test.ts`: a pull_request run whose merge commit has another tree prints the base-moved reason and `gh workflow run free.yml --ref feat, then gstack-ci-carry wait`. It fails without the change.
- `test/evidence.test.ts`: two `--shard=i/2` processes in one log record 6095, two shards with more than 64 KiB of per-test lines record 4000, and an incomplete, repeated or rerun shard set records the last summary. The sum cases fail without the change (3336 and 2000).
## [1.94.25.0] - 2026-11-03
- A lane label with a space or a colon, such as `pnpm lint` or `db:test`, no longer goes MISSING right after it carries (TRUT-7, truther#1170). `gstack-evidence` writes a label to the ledger with unsafe characters turned into `_`, but `check`, which `gstack-ship-release finish` and `accept` call, looked it up under the raw label, so /ship stopped with "not FRESH at the pushed head". `run`, `record-ci` and `check` now share one label spelling.
- A carry that doesn't reach FRESH no longer leaves a `ci_carried` entry in the ship receipt (TRUT-7: truther's receipt kept `pnpm_lint:41603968341/1` from the attempt that stopped). `gstack-evidence carried` now lists a CI row only when `check`'s own grading, at the 24-hour age /ship uses, calls it FRESH.
### Tests
- `test/ship-ci-carry.test.ts`: `accept` with the lanes `pnpm lint` and `db:test` carries both and checks them FRESH, and `gstack-evidence check` finds both. It failed before the fix (`EVIDENCE: MISSING label=pnpm lint`).
- `test/ship-ci-carry.test.ts`: a carried `pnpm lint` row aged past 24 hours grades STALE and leaves `carried` empty. It failed before the fix (the stale row was still listed).
## [1.94.24.0] - 2026-11-03
### Fixed
+1
View File
@@ -814,6 +814,7 @@ Write or change skill prose with `/write-skill` (edit ladder, levels, `gstack-sk
3. The dynamic template discovery picks it up for every host render and for `gstack/llms.txt`. Two things it does not fill in: a row in `SKILL_REQUIREMENTS` in `lib/doctor-components.ts` (the doctor's `--for <skill>` table; `test/doctor-components.test.ts` fails on a skill directory without one, and the same gen run rerenders `bin/gstack-doctor-components.sh`), and the catalog rows readers find the skill through (README.md, AGENTS.md, `docs/skills.md`, `docs/PROJECT_STRUCTURE.md`, the root `SKILL.md.tmpl` router)
4. Budget it: run `bun test/helpers/capture-context-budget.ts` and commit the refreshed `test/fixtures/context-budget.json` — the context-budget ratchet fails any skill without a ceiling; the catalog budget (`test/catalog-budget.test.ts`) fails until its cap is ratcheted by the protocol in its failure message, and the number is quoted in CLAUDE.md, this file and `docs/CONTRIBUTOR_REFERENCE.md`
5. Commit `{skill}/SKILL.md` (and the generated `{skill}/sections/preamble.md` when the template's `preamble-tier` is 2 or higher), external host output is generated at setup time and gitignored
6. Give it a row in the evidence ledger, `docs/inventory/evidence.json` ([ETHOS.md: 9. Earn Its Place](ETHOS.md#9-earn-its-place)): evidence that it helps on frontier models (`sources`, `models`, `prompt: preset`, `scope`, `masked: true`), or `status: untested` with its `reason`, a concrete `plan` (a `test/fixtures/skill-evals/<skill>/tasks.json` or an /eval-plan preregistration), an `owner` and a `measure_by` date. `test/inventory.test.ts` fails on a skill without a valid row; `bin/gstack-inventory --check` names the problem, and `bin/gstack-inventory` refreshes the dated snapshot in `docs/inventory.md`
## Conductor workspaces
+37
View File
@@ -273,6 +273,42 @@ the eval that jumps twenty points usually found a shortcut, not a capability.
---
## 9. Earn Its Place
Garry, 2026-11-02:
"Also work this principle of only include it if it is beneficial and good into gstack".
Every skill, step, instruction, check and default costs tokens and attention
on every run that loads it. So each one earns its place. A new part comes with
evidence that it helps on frontier models, or with a stated reason, an owner
and a date by which a fair test will measure it.
A fair test runs Claude Code's own system prompt (the `claude_code` preset,
not the Agent SDK's stub), on frontier models (today Opus 5.5 and GPT-6.1 Sol,
plus at most one named low-cost model), against a no-gstack arm that is masked
so it cannot read gstack. A result from the stub, from Haiku or Sonnet alone,
or from an unmasked arm is a lead, not a verdict.
When a fair test shows an existing part doesn't help, it gets one "Why null?"
cycle: did the model use the part, did the task fit, was there enough power and
room below the ceiling, and does today's model do the job without it. Then
keep it, fix the method, or cut it, and say which plainly.
Safety and security guards (careful, freeze, guard, unfreeze, cso) stay on
their stated reason: they stop the rare bad run that an average score can't see.
Measure the biggest parts first, the ones that load the most on the most runs.
[docs/inventory.md](docs/inventory.md) ranks every part by load and shows its
evidence; `docs/inventory/evidence.json` is the ledger, and
`bin/gstack-inventory --check` validates it.
**Anti-patterns:**
- A new step because it sounds wise, with no test planned.
- Keeping a part because it looked good on the stub or a small model.
- Cutting a part after one null result, without the Why-null cycle.
---
## How They Work Together
Boil the Ocean says: **cover the whole requirement with the least code.**
@@ -282,6 +318,7 @@ Attack the Premise says: **when fixes keep failing, test the belief they share.*
Separate Before Serializing says: **remove the race before you referee it.**
Make Operations Idempotent says: **design for the run that happens twice.**
Explain the Number says: **a result you cannot explain is not yet a result.**
Earn Its Place says: **every part shows it helps, or has a dated plan to.**
Together: search first, then build the complete version of the right thing.
The worst outcome is building a complete version of something that already
+1 -1
View File
@@ -1 +1 @@
1.94.24.0
1.94.34.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.94.24.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.94.34.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,
+3 -3
View File
@@ -11,11 +11,12 @@ announcement. Dispatching a reviewer never completes its step.
- Premises: accept reasonable ones (P6). Queue clearly-wrong/challenged premises
as User Challenges for Phase 4: assumption, reason and cost of proceeding.
The user decides there; never stop mid-pipeline.
- List each part the plan adds to gstack (skill, step, instruction, check, default) with its
evidence or owned, dated test plan; a missing one is a finding.
- Alternatives: pick highest completeness (P1). If tied, pick simplest (P5).
If top 2 are close → mark TASTE DECISION.
- Scope expansion: in blast radius + <1d CC → approve (P2). Outside → defer to TODOS.md (P3).
Duplicates → reject (P4). Borderline (3-5 files) → mark TASTE DECISION.
- All 11 review sections: run fully, auto-decide each issue, log every decision.
**Required execution checklist (CEO):**
@@ -38,8 +39,7 @@ Before every spec dispatch, including after each accepted spec fix, run:
```bash
bun "<SNAPSHOT_TOOL>" amend-input ceo "<ACTIVE_PLAN>" "<CEO_STEP0_CHECKPOINT>" "<RESTORE_PATH>" "<methodologyPath>"
```
This applies the recorded requirements and exports the complete current
`Implementation plan`. Keep returned `checkpointPath` as the amendment baseline;
Keep returned `checkpointPath` as the amendment baseline;
use returned `reviewInputPath` as `<CEO_SPEC_INPUT>`. Read that file at every
returned `readRanges` offset/limit through EOF, then read the CEO scope summary in full.
Reconcile dispositions, scope counts, proposal IDs and actual heading/test references
+3 -3
View File
@@ -9,11 +9,12 @@ announcement. Dispatching a reviewer never completes its step.
- Premises: accept reasonable ones (P6). Queue clearly-wrong/challenged premises
as User Challenges for Phase 4: assumption, reason and cost of proceeding.
The user decides there; never stop mid-pipeline.
- List each part the plan adds to gstack (skill, step, instruction, check, default) with its
evidence or owned, dated test plan; a missing one is a finding.
- Alternatives: pick highest completeness (P1). If tied, pick simplest (P5).
If top 2 are close → mark TASTE DECISION.
- Scope expansion: in blast radius + <1d CC → approve (P2). Outside → defer to TODOS.md (P3).
Duplicates → reject (P4). Borderline (3-5 files) → mark TASTE DECISION.
- All 11 review sections: run fully, auto-decide each issue, log every decision.
**Required execution checklist (CEO):**
@@ -36,8 +37,7 @@ Before every spec dispatch, including after each accepted spec fix, run:
```bash
bun "<SNAPSHOT_TOOL>" amend-input ceo "<ACTIVE_PLAN>" "<CEO_STEP0_CHECKPOINT>" "<RESTORE_PATH>" "<methodologyPath>"
```
This applies the recorded requirements and exports the complete current
`Implementation plan`. Keep returned `checkpointPath` as the amendment baseline;
Keep returned `checkpointPath` as the amendment baseline;
use returned `reviewInputPath` as `<CEO_SPEC_INPUT>`. Read that file at every
returned `readRanges` offset/limit through EOF, then read the CEO scope summary in full.
Reconcile dispositions, scope counts, proposal IDs and actual heading/test references
+7 -2
View File
@@ -26,6 +26,7 @@
# reports which root received the value and which root still overrides it.
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
set -euo pipefail
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
[ -r "$(dirname "$0")/gstack-state-root.sh" ] && . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
@@ -344,6 +345,8 @@ lookup_default() {
artifacts_sync_removals) echo "off" ;; # on | off — publish removals of synced files gone from disk
plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install
timeline_stop_hook) echo "yes" ;; # yes | no — controls ./setup timeline Stop hook registration (#2677)
skill_routing) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_routing ;; # on | off — the skill-routing UserPromptSubmit hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
skill_contracts) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_contracts ;; # on | off — the skill-contract Stop hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
redact_prepush_hook) echo "false" ;;
@@ -1139,7 +1142,8 @@ $_gstack_legacy_root"
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \
skill_routing skill_contracts; do
gstack_config_select "$KEY"
VALUE="$_gstack_cfg_value"
SOURCE="default"
@@ -1164,7 +1168,8 @@ $_gstack_legacy_root"
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \
skill_routing skill_contracts; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
;;
+107 -71
View File
@@ -37,7 +37,9 @@
* the tail is the stdout tail then the stderr tail, so a line's position says
* nothing about when it was printed across streams, and bun's own counts (on
* stderr) would otherwise outrank a 10k-test run whichever of the two ran
* first. Any summary when it did not speak follows the last-summary-wins rule;
* first. Any summary when it did not speak follows the last-summary-wins rule,
* except that a complete set of `bun test --shard=i/N` processes ending the log
* is summed (GBEV-3);
* anything else is "unknown".
* Three states, never merged:
* a count, 0 (ZERO-RUN, graded like STALE by `check`) and unknown (non-blocking).
@@ -95,7 +97,7 @@ import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/shi
import { versionAtRef } from "../lib/restamp";
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry";
import { runChanged } from "../lib/run-evidence";
import { DEFAULT_MAX_AGE_HOURS, runChanged } from "../lib/run-evidence";
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
import { buildRunCheck } from "../lib/pregate/run-check";
@@ -146,30 +148,48 @@ const TAIL_MAX_BYTES = 64 * 1024;
* Ring of the last TAIL_MAX_BYTES of each child stream, kept per stream: the
* pumps interleave by arrival, so a stderr summary read while stdout still has
* pipe-buffered bytes behind it would otherwise be pushed out of a shared ring.
* Lines leaving the ring that bun's shard sum reads (a "--shard=i/N: running"
* line, " N pass", " N fail", "Ran N tests across") are kept in order ahead of
* the tail, so every shard of a long sharded run still counts (GBEV-3).
*/
class OutputTail {
private readonly decoders = { stdout: new TextDecoder("utf-8"), stderr: new TextDecoder("utf-8") };
private readonly text = { stdout: "", stderr: "" };
private readonly kept = { stdout: [] as string[], stderr: [] as string[] };
private trim(origin: "stdout" | "stderr", text: string): string {
const cut = text.lastIndexOf("\n", text.length - TAIL_MAX_BYTES);
if (cut < 0) return text.slice(-TAIL_MAX_BYTES);
const lines = text.slice(0, cut).split("\n").filter((l) => /^(?:--shard=\d+\/\d+: running |\s*\d+ (?:pass|fail)\s*$|Ran \d+ tests? across )/.test(strip(l)));
this.kept[origin] = [...this.kept[origin], ...lines].slice(-4096);
return text.slice(cut + 1);
}
push(chunk: Uint8Array, origin: "stdout" | "stderr"): void {
this.text[origin] += this.decoders[origin].decode(chunk, { stream: true });
if (this.text[origin].length > TAIL_MAX_BYTES * 2) this.text[origin] = this.text[origin].slice(-TAIL_MAX_BYTES);
if (this.text[origin].length > TAIL_MAX_BYTES * 2) this.text[origin] = this.trim(origin, this.text[origin]);
}
end(): string {
const tails = (["stdout", "stderr"] as const).map((origin) => {
const text = this.text[origin] + this.decoders[origin].decode();
return text.length > TAIL_MAX_BYTES ? text.slice(-TAIL_MAX_BYTES) : text;
const tail = text.length > TAIL_MAX_BYTES ? this.trim(origin, text) : text;
return [...this.kept[origin], tail].join("\n");
});
return tails.join("\n");
}
}
/** The one spelling a lane label has in the ledger: run and record-ci write it, check reads it (`pnpm lint` is `pnpm_lint`, TRUT-7). */
const ledgerLabel = (raw: string): string => raw.replace(/[^a-zA-Z0-9._-]/g, "_");
const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const strip = (l: string) => l.replace(ANSI, "").replace(/\r$/, "");
const count = (text: string, word: string): number => Number(new RegExp(`(\\d+) ${word}\\b`).exec(text)?.[1] ?? 0);
/**
* Bun: the last "Ran N tests across M files" block; executed = its " N pass" +
* " N fail" lines. Bun always prints that counts block (" 0 pass" / " 0 fail"
* " N fail" lines. When that block ends a `bun test --shard=i/N` process (bun's
* "--shard=i/N: running" line since the previous block), the blocks of shards
* 1/N to N/N, in that order, that end the log are summed (a sharded runner prints
* every shard, GBEV-3); any other shard set counts the last block alone. Bun always prints that counts block (" 0 pass" / " 0 fail"
* even for an all-skip or empty run), so a terminal line with no " N pass"
* above it is a line gstack's quiet shard runner forwarded (it forwards the
* terminal line without the " N pass" line), not a bun summary:
@@ -186,7 +206,9 @@ async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number }
} catch {
warn("shard-engine classifier not importable; using the built-in bun summary parser");
}
for (let i = lines.length - 1; i >= 0; i--) {
const blocks: { at: number; ran: number; shard?: string }[] = [];
let since = 0;
for (let i = 0; i < lines.length; i++) {
if (!isTerminal(lines[i])) continue;
let pass: number | null = null;
let fail = 0;
@@ -197,13 +219,17 @@ async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number }
if (p) pass = Number(p[1]);
if (f) fail = Number(f[1]);
}
if (pass === null) {
if (/^Ran 0 tests? across/.test(lines[i])) return { at: i, ran: 0 };
continue;
}
return { at: i, ran: pass + fail };
const shard = lines.slice(since, i).map((l) => /^--shard=(\d+\/\d+): running /.exec(l)?.[1]).findLast(Boolean);
since = i + 1;
if (pass !== null) blocks.push({ at: i, ran: pass + fail, shard });
else if (/^Ran 0 tests? across/.test(lines[i])) blocks.push({ at: i, ran: 0 });
}
return null;
const last = blocks.at(-1);
if (!last) return null;
const total = Number(last.shard?.split("/")[1] ?? 1);
const set = blocks.slice(-total);
const complete = total > 1 && set.length === total && set.every((b, k) => b.shard === `${k + 1}/${total}`);
return { at: last.at, ran: complete ? set.reduce((n, b) => n + b.ran, 0) : last.ran };
}
/**
@@ -428,7 +454,7 @@ async function cmdRun(argv: string[]): Promise<number> {
const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" ");
const shell = cmdArgv.length === 1 ? bashCommandInvocation(cmdArgv[0]) : null;
const cmdSha = sha256(commandString);
label = label.replace(/[^a-zA-Z0-9._-]/g, "_");
label = ledgerLabel(label);
// Bookkeeping context — every piece is optional; failures only warn.
let paths: ReturnType<typeof ledgerPath> | undefined;
@@ -675,7 +701,8 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
const sep = argv.indexOf("--");
const opts = sep < 0 ? argv : argv.slice(0, sep);
const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; };
const label = get("--label")?.replace(/[^a-zA-Z0-9._-]/g, "_");
const raw = get("--label");
const label = raw === undefined ? undefined : ledgerLabel(raw);
const base = get("--base");
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
if (!label || !base || !command || opts.length !== 4) {
@@ -754,13 +781,76 @@ function cmdCarried(argv: string[]): number {
const wtree = currentWtree();
const latest = new Map<string, EvidenceRecord>();
for (const r of records) latest.set(r.label, r);
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef))
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef)
&& gradeRecord(r, { maxAgeHours: DEFAULT_MAX_AGE_HOURS, allowPaths: [], wtreeNow: wtree }).verdict === "FRESH")
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
if (argv.includes("--json")) console.log(JSON.stringify(carried));
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
return 0;
}
/** check's verdict for one ledger row; `carried` lists a CI row only when it grades FRESH here, so a carry that never reached FRESH leaves no receipt entry. */
function gradeRecord(latest: EvidenceRecord, o: { maxAgeHours?: number; expectCmd?: string; allowPaths: string[]; wtreeNow: string | undefined }): { verdict: "FRESH" | "STALE" | "ZERO-RUN"; reason: string } {
const { maxAgeHours, expectCmd, allowPaths, wtreeNow } = o;
const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown";
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
let reason = "";
if (latest.exit !== 0) {
verdict = "STALE";
reason = "recorded run failed";
} else if (testsRan === 0) {
// Zero-run green: exit 0 with nothing executed (all skipped, empty
// selection, "No tests found"). Graded like STALE under its own label;
// "unknown" (no recognised runner summary) stays non-blocking.
verdict = "ZERO-RUN";
reason = "runner reported 0 executed tests";
} else if (maxAgeHours !== undefined) {
const ageMs = Date.now() - Date.parse(latest.ts);
if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) {
verdict = "STALE";
reason = `older than ${maxAgeHours}h`;
}
}
if (verdict === "FRESH" && expectCmd !== undefined && sha256(expectCmd) !== latest.cmd_sha256) {
verdict = "STALE";
reason = "command changed (cmd_sha256 mismatch)";
}
if (verdict === "FRESH") {
// Content binding: identical working-tree fingerprint, or a diff confined
// to the allow-list. Any git failure (gc'd tree, not a repo) → STALE —
// never an error into the calling flow.
if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) {
// Stored fingerprints are re-validated before reaching git argv — a
// forged/corrupt ledger line must degrade, never inject options.
verdict = "STALE";
reason = !latest.wtree
? changedDuringRun.length
? `content changed during the run: ${changedDuringRun.join(", ")}`
: "record has no content fingerprint"
: !/^[0-9a-f]{40}$/.test(latest.wtree)
? "record has malformed fingerprint"
: "current fingerprint unavailable";
} else if (latest.wtree !== wtreeNow) {
const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]);
if (diff === undefined) {
verdict = "STALE";
reason = "content changed (fingerprint diff unavailable)";
} else {
const changed = diff.split("\n").map((s) => s.trim()).filter(Boolean);
const outside = changed.filter((f) => !allowPaths.some((a) => f === a || f.startsWith(a.replace(/\/$/, "") + "/")));
if (changed.length === 0 || outside.length === 0) {
reason = changed.length ? `diff confined to allow-paths (${changed.length} file(s))` : "";
} else {
verdict = "STALE";
reason = `content changed: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}`;
}
}
}
}
return { verdict, reason };
}
function cmdCheck(argv: string[]): number {
// Parse: repeated --label, each optionally followed (anywhere later) by its
// own --expect-cmd; pairing is positional — an --expect-cmd binds to the most
@@ -771,7 +861,7 @@ function cmdCheck(argv: string[]): number {
let allowPaths: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--label") wanted.push({ label: argv[++i] ?? "" });
if (a === "--label") wanted.push({ label: ledgerLabel(argv[++i] ?? "") });
else if (a === "--expect-cmd") {
if (wanted.length === 0) {
console.error("gstack-evidence: --expect-cmd requires a preceding --label");
@@ -820,63 +910,9 @@ function cmdCheck(argv: string[]): number {
continue;
}
const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown";
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : "";
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
let reason = "";
if (latest.exit !== 0) {
verdict = "STALE";
reason = "recorded run failed";
} else if (testsRan === 0) {
// Zero-run green: exit 0 with nothing executed (all skipped, empty
// selection, "No tests found"). Graded like STALE under its own label;
// "unknown" (no recognised runner summary) stays non-blocking.
verdict = "ZERO-RUN";
reason = "runner reported 0 executed tests";
} else if (maxAgeHours !== undefined) {
const ageMs = Date.now() - Date.parse(latest.ts);
if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) {
verdict = "STALE";
reason = `older than ${maxAgeHours}h`;
}
}
if (verdict === "FRESH" && expectCmd !== undefined && sha256(expectCmd) !== latest.cmd_sha256) {
verdict = "STALE";
reason = "command changed (cmd_sha256 mismatch)";
}
if (verdict === "FRESH") {
// Content binding: identical working-tree fingerprint, or a diff confined
// to the allow-list. Any git failure (gc'd tree, not a repo) → STALE —
// never an error into the calling flow.
if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) {
// Stored fingerprints are re-validated before reaching git argv — a
// forged/corrupt ledger line must degrade, never inject options.
verdict = "STALE";
reason = !latest.wtree
? changedDuringRun.length
? `content changed during the run: ${changedDuringRun.join(", ")}`
: "record has no content fingerprint"
: !/^[0-9a-f]{40}$/.test(latest.wtree)
? "record has malformed fingerprint"
: "current fingerprint unavailable";
} else if (latest.wtree !== wtreeNow) {
const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]);
if (diff === undefined) {
verdict = "STALE";
reason = "content changed (fingerprint diff unavailable)";
} else {
const changed = diff.split("\n").map((s) => s.trim()).filter(Boolean);
const outside = changed.filter((f) => !allowPaths.some((a) => f === a || f.startsWith(a.replace(/\/$/, "") + "/")));
if (changed.length === 0 || outside.length === 0) {
reason = changed.length ? `diff confined to allow-paths (${changed.length} file(s))` : "";
} else {
verdict = "STALE";
reason = `content changed: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}`;
}
}
}
}
const { verdict, reason } = gradeRecord(latest, { maxAgeHours, expectCmd, allowPaths, wtreeNow });
console.log(`EVIDENCE: ${verdict} ${detail}${reason ? ` reason=${reason}` : ""}`);
if (verdict !== "FRESH") allFresh = false;
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
// gstack-inventory — every gstack part, its load per run and its evidence (ETHOS.md: 9. Earn Its Place). Logic: lib/inventory.ts.
import { spawnSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { buildInventory, checkInventory, DOC_PATH, installReason, LEDGER_PATH, renderInventory } from "../lib/inventory";
const USAGE = `Usage: gstack-inventory [--root <gstack checkout>] [--check | --json]
gstack-inventory --help
Joins the evidence ledger (${LEDGER_PATH}) with gstack-context-bill's sizes and writes ${DOC_PATH}:
parts ranked by load per run, each part's evidence status, a "measure next" order (largest untested
first), shared-part sizes, paid dispatches per run for /ship and /autoplan, and the key list.
--check validates the ledger (every key has one row; each row has its status's fields), the quoted
dispatch sources and the shared-part files, and writes nothing. --json prints the inventory.
The root defaults to the git repo you run it in; it never writes into a gstack install.
Exit 0 when clean; 1 with one "<key>: <problem> (<CODE>)" line per problem; 2 on a bad argument.`;
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
const rootAt = args.indexOf("--root");
const rootArg = rootAt >= 0 ? args[rootAt + 1] : undefined;
const unknown = args.find((a, i) => !["--check", "--json", "--root"].includes(a) && !(rootAt >= 0 && i === rootAt + 1));
if (unknown !== undefined || (rootAt >= 0 && (!rootArg || rootArg.startsWith("--"))) || (args.includes("--check") && args.includes("--json"))) {
console.error(unknown !== undefined ? `gstack-inventory: unknown argument '${unknown}'; valid: --root <dir> --check --json --help` : USAGE);
process.exit(2);
}
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 });
const root = resolve(rootArg ?? (top.status === 0 ? top.stdout.trim() : process.cwd()));
if (rootArg === undefined && !existsSync(join(root, LEDGER_PATH))) {
console.error(`gstack-inventory: ${root} is not a gstack checkout (no ${LEDGER_PATH}); run it in your gstack checkout or pass --root <dir>`);
process.exit(2);
}
const report = (problems: { key: string; code: string; message: string }[]) => {
for (const p of problems) console.error(`gstack-inventory: ${p.key}: ${p.message} (${p.code})`);
};
if (args.includes("--check")) {
const checked = checkInventory(root);
report(checked.problems);
if (checked.problems.length) process.exit(1);
console.log(`INVENTORY: ok keys=${checked.keys.length} ledger=${LEDGER_PATH} dispatch_sources=${checked.dispatches.length}`);
process.exit(0);
}
const inv = buildInventory(root);
report(inv.problems);
if (args.includes("--json")) { console.log(JSON.stringify(inv, null, 2)); process.exit(inv.problems.length ? 1 : 0); }
if (inv.problems.length) { console.error(`gstack-inventory: ${DOC_PATH} not written; fix the problems above, then rerun`); process.exit(1); }
const installed = installReason(root);
if (installed) {
console.error(`gstack-inventory: refusing to write ${DOC_PATH} into ${root}: it is ${installed}, which gstack-upgrade owns; run it in your gstack checkout or pass --root <dir>`);
process.exit(2);
}
writeFileSync(join(root, DOC_PATH), renderInventory(inv));
console.log(`INVENTORY: wrote ${DOC_PATH} keys=${inv.keys.length} commit=${inv.commit}`);
+9 -7
View File
@@ -2,7 +2,7 @@
/**
* gstack-pregate — cheap checks before anything paid or remote (plan C8).
*
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
* gstack-pregate [run] [--stage preflight|tests|guards|all] [--only <check,..>] [--base <branch>]
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
@@ -33,7 +33,7 @@ import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/p
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { buildRunCheck } from '../lib/pregate/run-check';
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { CHECKS, CHECK_IDS, PREGATE_GUARDS_OUT_REL, PREGATE_OUT_REL, PREGATE_TIER2, PREPUSH_CHECK_IDS, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
import { describeSubstitution } from '../lib/pregate/remote-receipt';
@@ -75,7 +75,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
a fresh tree receipt, each lane's reuse verdict, migration collisions (base branch and open PRs), then
REGATE: needed|not-needed (docs/pregate.md#regate); \`gstack-pregate --against <head>\` is the same verb; exit 1 = needed
--stage preflight|tests|all default all
--stage preflight|tests|guards|all default all; guards is the pre-push housekeeping run (docs/pregate.md#prepush):
only ${PREPUSH_CHECK_IDS.join(' and ')}, each for the files the diff touched, written to ${PREGATE_GUARDS_OUT_REL}
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
@@ -151,10 +152,11 @@ function repoRoot(): string | null {
function outPath(root: string, rest: string[]): string { const o = flag(rest, '--out'); return o ? path.resolve(o) : path.join(root, PREGATE_OUT_REL); }
async function cmdRun(root: string, rest: string[], json: boolean): Promise<number> {
const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all';
if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; }
const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'guards' | 'all';
if (!['preflight', 'tests', 'guards', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests, guards or all\n'); return EXIT.usage; }
if (stage === 'guards' && (flag(rest, '--only') !== undefined || rest.includes('--remote'))) { process.stderr.write(`gstack-pregate: --stage guards runs only ${PREPUSH_CHECK_IDS.join(' and ')}; drop --only and --remote\n`); return EXIT.usage; }
const fromRun = flag(rest, '--from-run');
if (fromRun !== undefined && stage === 'preflight') { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; }
if (fromRun !== undefined && (stage === 'preflight' || stage === 'guards')) { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; }
const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean);
const unknown = (only ?? []).filter(id => !CHECK_IDS.includes(id));
if (unknown.length) { process.stderr.write(`gstack-pregate: unknown check(s) ${unknown.join(', ')}${unknown.some(u => (PREGATE_TIER2 as readonly string[]).includes(u)) ? ' (tier 2, not in this gstack)' : ''}; known: ${CHECK_IDS.join(', ')}\n`); return EXIT.usage; }
@@ -197,7 +199,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
});
}
const file = await runPregate(ctx, { stage, only, run: flag(rest, '--run') });
const out = outPath(root, rest);
const out = stage === 'guards' && !flag(rest, '--out') ? path.join(root, PREGATE_GUARDS_OUT_REL) : outPath(root, rest);
let written: string;
try { written = writePregateFile(root, out, file); }
catch (e: any) { process.stderr.write(resultLine('gstack-pregate', 'ARTIFACT_SCHEMA', e.message) + '\n'); return EXIT.fail; }
+24 -12
View File
@@ -16,10 +16,11 @@
* lib/review-pass.ts (the staged manifest) and lib/capy-review-pass.js (the Capy program that runs it).
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { BRIEF_ROLES, BriefError, briefFile, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, withWorkspace, type BriefRole, type Slots, type TreeCheck } from '../lib/review-brief';
import { BRIEF_ROLES, BriefError, briefFile, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, sliceDiff, sliceNote, withWorkspace, type BriefRole, type Slots, type TreeCheck } from '../lib/review-brief';
import { cleanMarkers, collect, fixableRaises, mayBeUnavailable, parseFindings, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger';
import { buildManifest, chunkCount, chunkOf, manifestPath, markReader, readManifest, renderStage, writeManifest, STAGES, type Extra, type Manifest, type PassReader, type ReaderState, type Stage } from '../lib/review-pass';
import type { Dispatch } from '../lib/review-depth';
@@ -108,8 +109,9 @@ function render(): number {
const diffBase = flag('--diff-base');
const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'targeted' ? path.join(ROOT, 'review/specialists/targeted.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined);
if (checklist && (role === 'specialist' || role === 'red-team' || role === 'targeted') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`);
const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined;
if (role === 'targeted') {
const merged = role === 'adversarial-native' && !!flag('--classes');
const brief = role === 'targeted' || merged ? readFile(merged ? path.join(ROOT, 'review/specialists/targeted.md') : checklist, 'targeted brief') : undefined;
if (role === 'targeted' || merged) {
const base = resolveBaseBranch(process.cwd(), flag('--base'));
const mb = mergeBaseWith(base);
const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined;
@@ -127,11 +129,19 @@ function render(): number {
if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
if (role === 'outside-voice') diff = branchDiff(diffBase, { exclude: flag('--exclude'), keep: keepItems });
}
let diffFile: Slots = {};
if (diff !== undefined) {
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
const f = path.join(path.resolve(out), 'outside-voice.diff');
const s = sliceDiff(diff, REVIEW_DIFF_MAX_BYTES);
fs.writeFileSync(f, s.text, { mode: 0o600 });
diffFile = { diff_file: f, diff_bytes: String(s.bytes), diff_sha256: createHash('sha256').update(s.text).digest('hex'), ...(s.sliced ? { diff_slice: sliceNote(s, REVIEW_DIFF_MAX_BYTES) } : {}) };
}
const slots: Slots = {
name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'),
learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','),
findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff,
classes: flag('--classes'), brief,
findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), ...diffFile,
classes: flag('--classes'), brief, ...(merged ? { checklist: path.join(ROOT, 'review/specialists/targeted.md') } : {}),
...(flag('--disclosure') ? { disclosure: flag('--disclosure'), policy_exclude: policyItems.map(quote).join(' ') || undefined, keep: keepItems.map(quote).join(' ') || undefined } : {}),
fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'),
};
@@ -144,8 +154,8 @@ function render(): number {
fs.writeFileSync(file, text, { mode: 0o600 });
const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl');
const sha = promptHash(text);
const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, ...(diffBase ? targetedRead(process.cwd(), diffBase) : { patch_id: 'none', head: null }) };
appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted, ...(disclosureDigest(flag('--disclosure')) ? { exclude: disclosureDigest(flag('--disclosure')) } : {}) });
const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(merged ? path.join(ROOT, 'review/specialists/targeted.md') : checklist), diff_base: diffBase ?? null, ...(diffBase ? targetedRead(process.cwd(), diffBase) : { patch_id: 'none', head: null }) };
appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted, ...(diffFile.diff_file ? { payload: { file: diffFile.diff_file, sha256: diffFile.diff_sha256 } } : {}), ...(disclosureDigest(flag('--disclosure')) ? { exclude: disclosureDigest(flag('--disclosure')) } : {}) });
process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`);
return EXIT.ok;
}
@@ -178,7 +188,7 @@ function ceiling(context: string, base: string, outDir: string, readers: Readonl
const at = r.diff_base ?? diffBase;
const d = selectionOf(at, r.exclude, r.keep);
if (!d.ok) return { exit: EXIT.fail, line: unreadableLine(context, r.reader, d, at, base, outDir) };
if (d.bytes > REVIEW_DIFF_MAX_BYTES) return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line };
if (d.bytes > REVIEW_DIFF_MAX_BYTES && r.reader !== 'outside-voice') return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line };
}
return null;
}
@@ -329,19 +339,21 @@ function program(): number {
* collected, and it was rendered on a committed diff (uncommitted never proves what was read). `head` names the commit it read:
* a later commit does not undo the read, and gstack-ship-receipt write binds the record only while that commit's product patch is
* the receipt's (GST-986: Step 11 fixes after the read printed status=failed findings=0 for a completed reader).
* R65's merged reader (an adversarial-native brief rendered with --classes) is a targeted dispatch too (design C7).
*/
function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string; head: string | null } | null {
const events = readLedger(ledger);
const render = events.filter(e => e.event === 'rendered' && e.reader === 'targeted').at(-1);
const render = events.filter(e => e.event === 'rendered' && (e.reader === 'targeted' || (e.reader === 'adversarial-native' && typeof e.brief_sha === 'string'))).at(-1);
if (!render) return null;
const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === 'targeted').at(-1)?.reader;
const key = String(render.reader);
const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === key).at(-1)?.reader;
const brief = rows.filter(r => r.kind === 'BRIEF' && r.reader === label).at(-1);
const output = rows.filter(r => r.kind === 'OUTPUT' && r.reader === label).at(-1);
const head = typeof render.head === 'string' && /^[0-9a-f]{40,64}$/.test(render.head) ? render.head : null;
const committed = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && head !== null;
const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && committed;
const ids = events.filter(e => e.event === 'output' && e.reader === 'targeted').at(-1)?.ids;
return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted', head };
const ids = events.filter(e => e.event === 'output' && e.reader === key).at(-1)?.ids;
return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: Array.isArray(ids) ? ids.length : 0, raised_by: key, head };
}
/**
+30 -7
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>] [--grant <approval id>] [--json]
* [--targeted-ran <targeted_patch_id>] [--grant <approval id>] [--fix-checks <n>] [--pr <n>] [--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
@@ -32,9 +32,12 @@ import { baseNote } from '../lib/gate-diff/git';
import { collectCommit, collectLive } from '../lib/review-collect';
import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
import { isUnavailable } from '../lib/ship-policy';
import { reviewPolicy } from '../lib/ship-policy-review';
import { platformReviewAt } from '../lib/review-platform';
import { loadPolicyAt } from '../lib/ship-policy-at';
import { classifyDiff, diffCommand, keepCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted';
import { RISK_CLASSES, type RiskClass } from '../lib/review-risk';
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth)
@@ -67,9 +70,19 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
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-checks <n> fix-checks already run in this invocation (R65's lean loop: the owner's grant adds one only after the first)
--pr <n> the PR whose platform review counts under review.platform on (default: this branch's PR)
--declare-risk <classes> risk classes the lane declares for the diff (comma list of ${RISK_CLASSES.join(', ')}): they join
the detected classes and only raise the depth; RISK_DECLARED: prints both (GST-1129)
--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;
Mode (R65, the base policy's review key): review.panel all (default) keeps today's panel on every class. review.panel risk
(opt-in; its one-reader shape has not passed a no-loss replay) runs lean off risk class, and review.panel off everywhere: lean runs one reader: risk class gets the merged reader on review.risk_model
(the native adversarial brief plus the checklists, the Red Team and the targeted brief), non-risk code the single reader at the
lane's reasoning (cross-family when it publishes numbers), docs and tests the core read; one fix-check after fixes, never more
than 2 rounds. review.platform on (any panel): a light diff with no numeric claims whose PR
carries capy-ai[bot]'s review at HEAD plans NEXT: platform and a planned SKIP for every reader.
Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data, review.risk_paths) 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, voices (gate class: Step 11 before any stop,
@@ -102,6 +115,8 @@ function main(): number {
if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; }
const pass1Depth = (flag('--pass1-depth') ?? 'full') as Depth;
if (!['light', 'standard', 'full'].includes(pass1Depth)) { process.stderr.write('gstack-review-plan: --pass1-depth must be light, standard or full\n'); return EXIT.usage; }
const fixChecks = Number(flag('--fix-checks') ?? (last === 'fix-check' ? '1' : '0'));
if (!Number.isInteger(fixChecks) || fixChecks < 0) { process.stderr.write('gstack-review-plan: --fix-checks needs an integer >= 0\n'); return EXIT.usage; }
const pass = Number(flag('--pass') ?? '0');
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;
@@ -111,6 +126,8 @@ function main(): number {
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 declared = list('--declare-risk');
if (has('--declare-risk') && (!declared.length || declared.some(c => !(RISK_CLASSES as readonly string[]).includes(c)))) { process.stderr.write(`gstack-review-plan: --declare-risk ${flag('--declare-risk') ?? ''}: each class is one of ${RISK_CLASSES.join(', ')} (a declaration only raises the depth)\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);
@@ -152,6 +169,9 @@ function main(): number {
const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? '';
const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null;
const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0;
const review = reviewPolicy(policy && !isUnavailable(policy) ? policy.policy : null);
const riskPaths = review.risk_paths.length ? { riskPaths: review.risk_paths } : {};
const platform = review.platform === 'on' ? platformReviewAt(root, head, { pr: flag('--pr') }) : null;
let tagged: Set<string> | null = null;
let gateTagged: number | null;
if (declares) {
@@ -179,7 +199,7 @@ function main(): number {
else if (git(['diff', '--quiet', 'HEAD', '--']) === null || collected.untracked.length) fixRange = unread('uncommitted or untracked edits: commit the fixes first');
else {
const commit = collectCommit(root, resolved, 'HEAD');
const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...(active ? { excluded: active.excluded } : {}) });
const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...riskPaths, ...(active ? { excluded: active.excluded } : {}) });
fixRange = range.errors.length ? unread(range.errors.map(e => `${e.code} ${e.input}`).join('; '))
: !commit.paths.length ? unread('the fix range is empty: commit the fix after the range base')
: { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) };
@@ -189,26 +209,29 @@ function main(): number {
const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none';
const brief = targetedBriefSha();
const plan = planReview({
...collected, ...exclusionInput, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
...collected, ...exclusionInput, ...riskPaths, declared: declared as RiskClass[], shape: { panel: review.panel, readerModel: review.reader_model, platform: review.platform, head, platformReview: platform?.ok ? platform.review : null }, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant },
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, fixChecks: has('--fix-checks') ? fixChecks : undefined },
});
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
process.stdout.write(JSON.stringify({ ...rest, review_policy: review, platform_review: platform ? (platform.ok ? platform.review : { none: platform.reason }) : null, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n');
return EXIT.ok;
}
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, `REVIEW_BASE: ${ref} ${baseSha.slice(0, 12)}`,
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
out.push(...excludeLines(exclusion, base));
out.push(`REVIEW_MODE: ${plan.mode} panel=${review.panel} reader_model=${review.reader_model} platform=${review.platform}`);
if (declared.length) out.push(`RISK_DECLARED: classes=${plan.declared.join(',')} detected=${plan.detected.join(',') || 'none'}`);
if (platform) out.push(platform.ok ? `PLATFORM_REVIEW: ${platform.review.id} reviewer=${platform.review.reviewer} head=${platform.review.head}` : `PLATFORM_REVIEW: none (${platform.reason})`);
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`);
for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`);
for (const d of plan.dispatches) {
const key = d.name.split(' ')[0];
out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`);
out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.model ? ` model=${d.model}` : ''}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.classes ? ` classes=${d.classes.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`);
if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`);
const keepCmd = keepCommand(d);
if (keepCmd) out.push(`DIFF_KEEP: ${key} ${keepCmd}`);
+17 -3
View File
@@ -51,7 +51,8 @@ import {
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
import { planRecheck, readRecheck, recheckPacket } from '../lib/ship-recheck';
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, platformReceiptDepth, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { RISK_CLASSES, type RiskClass } from '../lib/review-risk';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
@@ -107,6 +108,11 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
digest a refusal prints passes too. After a refusal, rerun the review and pass the digest its verify prints.
--no-stop <json|@file> (a JSON array of {class, reason, evidence}: each routine stop the run recorded and went on past, class one of
scoped-fix-check, test-timeout-fix, pregate-flake-rerun, stamp-only-carry, autoplan-regate); recorded as no_stop.
--declare-risk <classes> (the same list gstack-review-plan --declare-risk took): joins review_depth's risk and only raises
its depth; review_depth.declared and .detected record both lists (GST-1129). An unknown class is RECEIPT_INVALID.
--platform-review <id> (NEXT: platform's PLATFORM_REVIEW: id): records review_depth platform with capy-ai[bot]'s review <id> at HEAD;
refused (RECEIPT_INVALID) unless the base policy has review.platform on, HEAD needs light review of a docs or tests diff with
no numeric, results or prereg claims, and that review is on the PR at HEAD. review_depth.mode records lean, panel or platform.
--review-targeted <json|@file> ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
@@ -244,8 +250,16 @@ function cmdWrite(rest: string[], json: boolean): number {
const line = reviewLoopExceptionLine(loopException.exception);
if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`);
}
const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
const reviewDepth = review.depth;
const declared = (flag(rest, '--declare-risk') ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (rest.includes('--declare-risk') && (!declared.length || declared.some(c => !(RISK_CLASSES as readonly string[]).includes(c)))) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--declare-risk ${flag(rest, '--declare-risk') ?? ''}: each class is one of ${RISK_CLASSES.join(', ')}; nothing written`) + '\n'); return EXIT.fail; }
const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head, declared as RiskClass[]);
let reviewDepth = review.depth;
const platformId = flag(rest, '--platform-review');
if (platformId !== undefined) {
const p = platformReceiptDepth(repoRoot, base, reviewDepth, headSha ?? head, platformId, flag(rest, '--pr'));
if (!p.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${p.message}; nothing written`) + '\n'); return EXIT.fail; }
reviewDepth = p.depth;
}
if (review.exclude.state === 'refused') { for (const l of review.exclude.lines) process.stderr.write(`${l}; nothing written\n`); return EXIT.refused; }
const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
+2 -1
View File
@@ -11,7 +11,8 @@ Names the skill's kind: gstack-checkout (edit SKILL.md.tmpl, then bun run gen:sk
gstack-install (never edit; gstack-upgrade overwrites it) or user. BASELINE is the newest commit
with a "Skill-Diet: <name>" trailer; DUE means the authored file grew over 10% since then.
In a gstack checkout, EVAL TASKS names test/fixtures/skill-evals/<name>/tasks.json (or none) and
EVAL_TASKS_INVALID flags a file that breaks docs/designs/SKILL_EVAL_CONTRACT.md's task format.
EVAL_TASKS_INVALID flags a file that breaks docs/designs/SKILL_EVAL_CONTRACT.md's task format, and
EVIDENCE prints the skill's row in docs/inventory/evidence.json: its status and source, plan or reason.
A name must match exactly; pass a path when it names more than one skill.
Exit 0 on a report, 2 on SKILL_NOT_FOUND, SKILL_AMBIGUOUS or SKILL_MD_MISSING (each with a fix line).`;
+14 -3
View File
@@ -4,19 +4,30 @@
*
* Usage:
* gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]
* gstack-skill-route --default <skill_routing|skill_contracts>
*
* Prints the switch state, the threshold, every candidate skill with its
* score and best trigger, the route the hook would take and the exact line it
* would inject. Works with the switch off (a dry run): it never injects
* anything. Exit 0, or 2 on a usage error. Docs: docs/skill-routing-and-contracts.md.
* anything. --default prints a switch's SWITCH_DEFAULT (on or off), the value
* setup and gstack-config use when the switch is unset. Exit 0, or 2 on a usage error. Docs: docs/skill-routing-and-contracts.md.
*/
import {
ROUTE_THRESHOLD, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills,
ROUTE_THRESHOLD, SWITCH_DEFAULT, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills,
} from '../lib/skill-route';
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]';
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]\n gstack-skill-route --default <skill_routing|skill_contracts>';
const args = process.argv.slice(2);
if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); }
if (args[0] === '--default') {
const name = args[1] ?? '';
if (!Object.hasOwn(SWITCH_DEFAULT, name)) {
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing or skill_contracts\n${USAGE}\n`);
process.exit(2);
}
process.stdout.write(`${SWITCH_DEFAULT[name as keyof typeof SWITCH_DEFAULT]}\n`);
process.exit(0);
}
const at = args.indexOf('--explain');
const prompt = at >= 0 ? args[at + 1] : undefined;
const tAt = args.indexOf('--threshold');
+26 -17
View File
@@ -2215,7 +2215,11 @@ export class BrowserManager {
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
// tell that the other two paths strip.
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs(), ...this.proxyLaunchArgs()];
if (extensionPath) {
if (extensionPath && isCustomChromium()) {
// Same rule as launchHeaded(): a build that bakes the extension in
// crashes when it is loaded twice.
console.log('[browse] Handoff: custom Chromium bakes in the extension — skipping --load-extension');
} else if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
// Auth token is served via POST /extension-token (pinned-origin
@@ -2234,27 +2238,31 @@ export class BrowserManager {
fs.mkdirSync(userDataDir, { recursive: true });
cleanSingletonLocks(userDataDir);
// Self-heal probe (#2242): handoff always launches the Playwright-cache
// bundle (this launchPersistentContext call passes no executablePath),
// so a bundle poisoned by the old in-place rebrand would GPU-crash here
// exactly like launchHeaded(). Same probe, same contract: a
// GSTACK_CHROMIUM_PATH bundle is never passed in. The rethrown typed
// error surfaces through the outer catch as the actionable
// "Cannot open headed browser" message, headless browser untouched.
try {
probePoisonedChromiumBundle(chromium.executablePath());
} catch (err: unknown) {
if (err instanceof PoisonedBundleError) throw err;
// Probe failures (no bundle yet, EACCES) fall through to launch.
// #3907: same custom-binary support as launch()/launchHeaded().
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
// Self-heal probe (#2242): without a custom path, handoff launches the
// Playwright-cache bundle, so a bundle poisoned by the old in-place
// rebrand would GPU-crash here exactly like launchHeaded(). Same probe,
// same contract: a GSTACK_CHROMIUM_PATH bundle is never probed. The
// rethrown typed error surfaces through the outer catch as the
// actionable "Cannot open headed browser" message, headless browser
// untouched.
if (!executablePath) {
try {
probePoisonedChromiumBundle(chromium.executablePath());
} catch (err: unknown) {
if (err instanceof PoisonedBundleError) throw err;
// Probe failures (no bundle yet, EACCES) fall through to launch.
}
}
// T1: same automation-tell-stripping defaults as launchHeaded().
// The handoff path (headless → headed re-launch) takes the same
// anti-detection posture.
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
// XProtect self-heal wrapper (P0 #2554): handoff always launches the
// Playwright-cache bundle (no executablePath), so the heal applies
// exactly as in launch()/launchHeaded().
// XProtect self-heal wrapper (P0 #2554), scoped exactly as in
// launch()/launchHeaded(): never heals a GSTACK_CHROMIUM_PATH bundle.
newContext = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
headless: false,
...(this.xvfb ? { env: { ...process.env, DISPLAY: this.xvfb.display } } : {}),
@@ -2268,10 +2276,11 @@ export class BrowserManager {
chromiumSandbox: shouldEnableChromiumSandbox(),
args: launchArgs,
viewport: null,
...(executablePath ? { executablePath } : {}),
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
timeout: 15000,
}));
}), { usesCustomExecutable: Boolean(executablePath) });
if (this.closing) {
await newContext.close().catch(() => {});
throw new Error('Browser is shutting down');
@@ -1,5 +1,9 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { isCustomChromium } from '../src/browser-manager';
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { chromium } from 'playwright';
import { BrowserManager, isCustomChromium } from '../src/browser-manager';
describe('browser-manager: isCustomChromium', () => {
let origPath: string | undefined;
@@ -65,3 +69,72 @@ describe('browser-manager: isCustomChromium', () => {
expect(isCustomChromium()).toBe(false);
});
});
// #3907: handoff() must launch the browser GSTACK_CHROMIUM_PATH names, like
// launch() and launchHeaded(). The Playwright launch is a spy that rejects, so
// no window opens and handoff() returns its "Cannot open headed browser" error.
describe('handoff: GSTACK_CHROMIUM_PATH', () => {
const EXTENSION = '/opt/gstack/extension';
const ENV_KEYS = ['GSTACK_CHROMIUM_PATH', 'GSTACK_CHROMIUM_KIND', 'CHROMIUM_PROFILE'] as const;
const saved: Record<string, string | undefined> = {};
let tmpDir: string;
let launch: ReturnType<typeof spyOn>;
let bundledPath: ReturnType<typeof spyOn>;
function promotable(): BrowserManager {
const manager = new BrowserManager();
Object.assign(manager as unknown as Record<string, unknown>, {
browser: {},
context: {},
saveState: async () => ({ cookies: [], pages: [] }),
ensureHeadedDisplay: async () => {},
findExtensionPath: () => EXTENSION,
});
return manager;
}
const launchOptions = () => (launch.mock.calls[0] as [string, { executablePath?: string; args: string[] }])[1];
beforeEach(() => {
for (const key of ENV_KEYS) saved[key] = process.env[key];
delete process.env.GSTACK_CHROMIUM_PATH;
delete process.env.GSTACK_CHROMIUM_KIND;
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'handoff-custom-chromium-'));
process.env.CHROMIUM_PROFILE = path.join(tmpDir, 'profile');
launch = spyOn(chromium, 'launchPersistentContext').mockRejectedValue(new Error('launch spy'));
bundledPath = spyOn(chromium, 'executablePath').mockReturnValue(path.join(tmpDir, 'no-bundle', 'chrome'));
});
afterEach(() => {
launch.mockRestore();
bundledPath.mockRestore();
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('launches the GSTACK_CHROMIUM_PATH binary and never probes the Playwright bundle', async () => {
process.env.GSTACK_CHROMIUM_PATH = '/opt/chrome/chrome';
expect(await promotable().handoff('custom')).toContain('Cannot open headed browser');
expect(launch).toHaveBeenCalledTimes(1);
expect(launchOptions().executablePath).toBe('/opt/chrome/chrome');
expect(bundledPath).not.toHaveBeenCalled();
});
test('without GSTACK_CHROMIUM_PATH it launches the probed Playwright bundle', async () => {
expect(await promotable().handoff('bundled')).toContain('Cannot open headed browser');
expect('executablePath' in launchOptions()).toBe(false);
expect(bundledPath).toHaveBeenCalled();
expect(launchOptions().args).toContain(`--load-extension=${EXTENSION}`);
});
test('a build that bakes the extension in never gets it loaded twice', async () => {
process.env.GSTACK_CHROMIUM_PATH = '/Applications/GBrowser.app/Contents/MacOS/GBrowser';
await promotable().handoff('baked');
expect(launchOptions().executablePath).toBe('/Applications/GBrowser.app/Contents/MacOS/GBrowser');
expect(launchOptions().args.filter((arg) => arg.includes('extension'))).toEqual([]);
});
});
@@ -66,7 +66,7 @@ const nodeInfo = JSON.parse(nodeProbe.stdout);
if (nodeInfo.architecture !== process.arch || !['x64', 'arm64'].includes(process.arch)) incomplete('runtime_architecture_mismatch');
const require = createRequire(import.meta.url);
const playwrightVersion = require('playwright/package.json').version;
if (playwrightVersion !== '1.62.1') incomplete('playwright_1_62_1_required');
if (playwrightVersion !== '1.64.1') incomplete('playwright_1_64_1_required');
const versionProbe = spawnSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
'-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND,
], { env: { ...environment, GSTACK_QUALIFY_BROWSER_EXE: executable }, encoding: 'utf8', timeout: 10_000, windowsHide: true });
+1 -1
View File
@@ -213,7 +213,7 @@ async function qualifiedAdapterFixture() {
const receipt = {
browserName: 'Edge', architecture: runtime.architecture, windowsRelease: release(),
executableSha256: '0'.repeat(64), nodeVersion: runtime.version, bunVersion: Bun.version,
playwrightVersion: '1.62.1', sourceHashes,
playwrightVersion: '1.64.1', sourceHashes,
};
const virtualRoot = `C:\\gstack-${path.basename(fixture)}`;
const windowsEnv = { LOCALAPPDATA: `${virtualRoot}\\Local`, PROGRAMFILES: `${virtualRoot}\\Apps`, 'PROGRAMFILES(X86)': `${virtualRoot}\\Apps32` };
+23 -7
View File
@@ -1,8 +1,10 @@
/**
* #2160/#1989: playwright-core is bun-patched to pass windowsHide at its two
* #2160/#1989: playwright-core must pass windowsHide at its two
* Windows-visible child_process sites — the browser launch spawn (Node
* defaults windowsHide to FALSE for spawn, so browser children could flash a
* console window) and the force-kill taskkill spawnSync. This is the repo's
* console window) and the force-kill taskkill spawnSync. The launch site is
* bun-patched; the taskkill site is upstream since Playwright 1.64 (#3907),
* so only the installed-tree check below covers it. This is the repo's
* first patchedDependencies entry; these static checks pin the three-legged
* coherence (patch file ↔ package.json ↔ installed tree) so a playwright
* bump that forgets to re-target the patch fails CI instead of silently
@@ -35,15 +37,14 @@ describe('playwright-core windowsHide patch (#2160, #1989)', () => {
expect(pkg.patchedDependencies[key]).toBe(`patches/playwright-core@${version}.patch`);
});
test('the patch file exists and carries both windowsHide sites', () => {
test('the patch file exists and carries the launch windowsHide site', () => {
const version = installedPlaywrightCoreVersion();
const patchPath = path.join(ROOT, 'patches', `playwright-core@${version}.patch`);
expect(fs.existsSync(patchPath)).toBe(true);
expect(fs.existsSync(path.join(ROOT, 'patches', 'playwright-core@1.62.1.patch'))).toBe(false);
const patch = fs.readFileSync(patchPath, 'utf-8');
// Launch spawnOptions site.
expect(patch).toContain('+ windowsHide: true,');
// taskkill force-kill site.
expect(patch).toContain('shell: true, windowsHide: true');
});
test('bun.lock is coherent: the lockfile records the patched dependency', () => {
@@ -63,13 +64,28 @@ describe('playwright-core windowsHide patch (#2160, #1989)', () => {
// Windows Free Tests 40656299153 and 41056150287 (cookie-picker-ui, serial retry passed): goto failed with
// net::ERR_NO_BUFFER_SPACE on 127.0.0.1. Chromium's TcpPortRandomizationWin raises WSAENOBUFS with ports free;
// microsoft/playwright#43605 disables it in 1.65, and the 1.62.1 patch backports it; drop this block at 1.65.
// microsoft/playwright#43605 disables it in 1.65, and the 1.64.1 patch backports it; drop this block at 1.65.
describe('playwright-core TcpPortRandomizationWin backport (microsoft/playwright#43605)', () => {
test('the patch and the installed tree disable the feature in the --disable-features list', () => {
expect(fs.readFileSync(path.join(ROOT, 'patches', 'playwright-core@1.62.1.patch'), 'utf-8')).toContain('+ "TcpPortRandomizationWin"');
expect(fs.readFileSync(path.join(ROOT, 'patches', 'playwright-core@1.64.1.patch'), 'utf-8')).toContain('+ "TcpPortRandomizationWin"');
const bundle = fs.readFileSync(path.join(ROOT, 'node_modules', 'playwright-core', 'lib', 'coreBundle.js'), 'utf-8');
const list = bundle.slice(bundle.indexOf('disabledFeatures = ['), bundle.indexOf('].filter(Boolean);', bundle.indexOf('disabledFeatures = [')));
expect(list).toContain('"TcpPortRandomizationWin"');
expect(bundle).toContain('"--disable-features=" + disabledFeatures.join(",")');
});
});
// #3907: CVE-2026-106378 (macOS GPU-process sandbox escape, crbug.com/507596239) is fixed by Chromium
// commit 0c2f3e54b265 (main@{#1695786}), first tagged in 155.0.8059.12. Playwright 1.62.1 shipped
// 151.0.7922.34, which lacks it. A Playwright pin whose bundled Chromium is older reopens the escape.
describe('bundled Chromium carries the CVE-2026-106378 fix (#3907)', () => {
test('playwright-core pins Chromium 155.0.8059.12 or later', () => {
const browsers = JSON.parse(fs.readFileSync(
path.join(ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
)) as { browsers: Array<{ name: string; browserVersion: string }> };
const version = browsers.browsers.find((b) => b.name === 'chromium')!.browserVersion.split('.').map(Number);
const floor = [155, 0, 8059, 12];
const cmp = version.map((part, i) => part - floor[i]).find((d) => d !== 0) ?? 0;
expect(cmp).toBeGreaterThanOrEqual(0);
});
});
+1 -1
View File
@@ -251,7 +251,7 @@ describe('cookie-import domain validation', () => {
reset: 'not_requested', verification: { verified: false, reason: 'not_requested' }, message: 'Cookie copy complete.',
});
let currentUrl = 'https://example.test';
const page = { url: () => currentUrl, isClosed: () => false };
const page = { url: () => currentUrl, isClosed: () => false } as any;
const session = { getPage: () => page, getActiveFrameOrPage: () => page, getFrame: () => null } as any;
const manager = { trackCookieImportDomains() {} } as any;
try {
+4 -6
View File
@@ -13,7 +13,7 @@
"html-to-docx": "1.8.0",
"html-to-vdom": "0.7.0",
"marked": "^18.1.1",
"playwright": "^1.62.1",
"playwright": "^1.64.1",
"sharp": "^0.35.4",
"socks": "^2.8.10",
"xterm": "^5.3.0",
@@ -28,7 +28,7 @@
},
},
"patchedDependencies": {
"playwright-core@1.62.1": "patches/playwright-core@1.62.1.patch",
"playwright-core@1.64.1": "patches/playwright-core@1.64.1.patch",
},
"overrides": {
"@hono/node-server": "2.1.6",
@@ -335,8 +335,6 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -469,9 +467,9 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
"playwright": ["playwright@1.64.1", "", { "dependencies": { "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA=="],
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
"playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="],
"prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="],
+7 -7
View File
@@ -1,17 +1,17 @@
# How we work: the gstack software factory on Capy
# How we work: gstack's factory on Capy
What seven Captain crews converged on (2026-10-29). Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person and never merge without their word. Story: capy/README.md; examples: your org Drive's software-factory/README.md, if any.
What seven Captain crews agreed on. Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person; never merge without their word. Story: capy/README.md; examples: org Drive software-factory/README.md.
1. **Plan, then a yes.** New functionality starts with /autoplan sized to the work; the build waits for "do it". [SKILL.md: Plan, approve, ship]
2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY quoting gstack's verify output verbatim, and stop. The merge owner merges on green and assigns versions. [docs/captain.md: The READY contract]
3. **Block only on the floor.** A finding blocks if it would fail a test, break production, or lose data or secrets; the rest goes in the PR body or next wave. [docs/review-policy.md]
4. **Fix waves.** When main goes red, hold /ship while one owner finds the root cause; bring main in once, as your rules allow. Fold deferred findings and test follow-ups into one PR. [docs/triage.md]
5. **Severe first, same gates.** A severe user-facing bug jumps the queue; the plan can be short; receipt, checks and merge owner stay.
6. **Trade findings, not small talk.** Another crew's finding goes to its Captain, through yours, with the evidence.
5. **Severe first, same gates.** A severe user-facing bug jumps the queue with a short plan; receipt, checks and merge owner stay.
6. **Trade findings, not small talk.** Another crew's finding goes to its Captain, through yours, with evidence.
7. **Measure before you act.** Run the real system on the merged tree you ship; a real run outranks a reviewed plan. Each number names its run, commit or uncapped query; say what you did not verify.
8. **Evals decide, and losses get reported.** Preregister bars, cap and stop rule before paying [/eval-plan]. Cheap proof (a keyless fake, a smoke) before the paid run. Use controls (no-skill and placebo arms, clean decoys). Report use and pass apart; quote spend from the meter.
9. **Flakes: one rerun, a ledger, a daily fix.** Only the rerun the policy allows, never a harness or infra red; a second red is real; no test is loosened. [docs/captain.md: The daily flaky pass]
8. **Evals decide, and losses get reported.** Preregister bars, cap and stop rule before paying [/eval-plan]. Cheap proof (a keyless fake, a smoke) first. Controls: no-skill and placebo arms, clean decoys. Report use and pass apart; quote spend from the meter. A new part needs proof it helps or a dated test plan [ETHOS.md: 9. Earn Its Place].
9. **Flakes: one rerun, a ledger, a daily fix.** Only the policy's rerun, never for a harness or infra red; a second red is real; never loosen a test. [docs/captain.md: The daily flaky pass]
10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast needing no reply goes in the Captain's notes; never reply just to acknowledge.
11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: <code> hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered simple steps, the reply word. Never re-ask a standing rule; check what you can yourself. [docs/captain.md: The ask format]
11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: <code> hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered steps, the reply word. Never re-ask a standing rule; check what you can yourself. [docs/captain.md: The ask format]
12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain); past the context limit, hand off. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the token counter. [docs/ship-budget.md]
13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference]
+18
View File
@@ -1871,6 +1871,24 @@ own attempts. The arm gets:
- the same budget treatment: it is priced and reserved in the pilot like every
other arm.
**Masking.** On Capy, the owner's user-scope instructions tell every session to
load the user-drive gstack skill, and eval run agents inherit them: in one
checkpoint (24 runs, 3 arms) every run in every arm tried to read that skill in
its first minute. So `## No-gstack arm` also needs three lines, and `prereg
--check` refuses a missing, blank or `none` one (`PREREG_NO_GSTACK_ARM`):
- `masking:` an empty directory bind-mounted over `~/.capy/drive/user-garry-tan`
and `~/.capy/drive/org-gstack-gbrain` before the run agent starts, or a
container that sees no Capy drive;
- `install:` no gstack on the arm's machine (`~/.local/share/gstack`,
`~/.claude/skills`), checked before the run;
- `audit:` a per-arm audit of drive and skill reads.
An arm that read a gstack skill file is contaminated and gets zero credit. A
no-gstack result that ran before this rule without a mask is not evidence for
an R48 verdict until it is re-checked; the evidence ledger records it under
`prior` with `unmasked` ([docs/inventory.md](inventory.md)).
**Reporting.** The no-gstack result is one row beside the variants in every
results table and every verdict, including when it wins. A comparison that
drops the row has not reported its result. `gstack-eval-plan results --check
+249
View File
@@ -0,0 +1,249 @@
# gstack inventory: every part, its load per run and its evidence
Generated by `bin/gstack-inventory` from [evidence.json](inventory/evidence.json), the hand-kept ledger, and lib/context-bill.ts. Measured at commit cd67c41d8 (2026-11-02). This is a dated snapshot: rerun `bin/gstack-inventory` to refresh it; `bin/gstack-inventory --check` validates the ledger. The rule: [ETHOS.md: 9. Earn Its Place](../ETHOS.md#9-earn-its-place).
Token counts are offline estimates (estimate: calibrated bytes/token per content class, about ±40%), not billed tokens.
| Status | Parts |
|---|---|
| helps | 0 |
| no-effect | 1 |
| hurts | 0 |
| untested, with a plan | 17 |
| untested, plan none | 48 |
| safety | 5 |
## Parts by load per run
Load per run: a skill's SKILL.md plus the files it must read on every use (`perInvocationTokens` in lib/context-bill.ts); a shared part's files. Tier 2+ skills also read the shared preamble once per session (its own row). On demand: the skill's other sections/ and references/ files, read when a step asks for them; files a shared row also counts, such as /ship's review sections, appear in both. Always-on: frontmatter every session's skill scanner reads.
| Rank | Part | Kind | Load per run (tokens) | On demand (tokens) | Always-on (tokens) | Loads | Status | Source, plan or reason |
|---|---|---|---|---|---|---|---|---|
| 1 | ship-review-panel | shared | 22,830 | n/a | n/a | every /ship run, by depth | untested | plan: GEVA-2 registry row R56-1 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 2 | design-review | skill | 20,159 | 7,778 | 101 | every use | untested | plan: GEVA-2 registry row R56-9 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 3 | ship | skill | 14,587 | 69,678 | 91 | every use | untested | plan: GEVA-2 registry row R56-4 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 4 | plan-ceo-review | skill | 14,394 | 21,203 | 313 | every use | untested | plan: GEVA-2 registry row R56-5 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 5 | review | skill | 13,990 | 41,146 | 75 | every use | untested | plan: R2 (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-02-geva-122-r2-amendment (#129, main eae6efc), told /review vs a no-gstack arm, Opus 5.5, preset on both arms (grandfathered) |
| 6 | devex-review | skill | 13,911 | 0 | 74 | every use | untested | plan: GEVA-2 registry row R56-11 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 7 | plan-design-review | skill | 13,351 | 12,986 | 79 | every use | untested | plan: GEVA-2 registry row R56-7 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 8 | retro | skill | 12,257 | 1,514 | 225 | every use | untested | plan: GEVA-2 registry row R56-12 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 9 | plan-devex-review | skill | 11,104 | 20,474 | 80 | every use | untested | plan: GEVA-2 registry row R56-6 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 10 | autoplan-phases | shared | 10,709 | n/a | n/a | every /autoplan run, by scope | untested | plan: folded into GEVA-2 registry row R56-3 (autoplan); proposed, not yet preregistered (grandfathered) |
| 11 | office-hours | skill | 10,424 | 23,078 | 280 | every use | untested | plan: none (grandfathered) |
| 12 | autoplan | skill | 10,288 | 18,701 | 394 | every use | untested | plan: GEVA-2 registry row R56-3 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 13 | plan-eng-review | skill | 9,837 | 25,963 | 74 | every use | untested | plan: GEVA-2 registry row R56-2 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 14 | design-consultation | skill | 9,557 | 10,864 | 282 | every use | untested | plan: GEVA-2 registry row R56-10 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 15 | setup-gbrain | skill | 9,099 | 10,921 | 106 | every use | untested | plan: none (grandfathered) |
| 16 | codex | skill | 8,991 | 10,358 | 68 | every use | untested | plan: none (grandfathered) |
| 17 | sync-gbrain | skill | 8,863 | 0 | 85 | every use | untested | plan: none (grandfathered) |
| 18 | design-shotgun | skill | 8,470 | 1,208 | 248 | every use | untested | plan: none (grandfathered) |
| 19 | plan-tune | skill | 8,377 | 0 | 107 | every use | untested | plan: none (grandfathered) |
| 20 | spec | skill | 8,305 | 7,759 | 92 | every use | untested | plan: none (grandfathered) |
| 21 | browse | skill | 8,125 | 5,597 | 81 | every use | untested | plan: none (grandfathered) |
| 22 | design-html | skill | 8,096 | 5,844 | 82 | every use | untested | plan: none (grandfathered) |
| 23 | land-and-deploy | skill | 7,652 | 16,188 | 63 | every use | untested | plan: GEVA-2 registry row R56-8 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 24 | benchmark | skill | 7,430 | 961 | 72 | every use | untested | plan: none (grandfathered) |
| 25 | catalog | shared | 7,291 | n/a | 7,291 | every session | untested | plan: Run D (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-125-untold-discovery-prereg (#125, main 9820b14), untold gstack vs placebo vs no gstack, preset vs stub, Opus 5.5, GPT-6.1 Sol and particle-1; also measures /investigate's description (grandfathered) |
| 26 | investigate | skill | 7,086 | 3,729 | 490 | every use | no-effect | sources: GST-814 (HARN-1, 2026-11-02), org-gstack-gbrain handoffs/HARN-14/hard-follow-2026-11/decision-rule.md; R48 CUT-CANDIDATE |
| 27 | canary | skill | 7,034 | 0 | 66 | every use | untested | plan: none (grandfathered) |
| 28 | qa | skill | 6,951 | 13,877 | 74 | every use | untested | plan: none (grandfathered) |
| 29 | scrape | skill | 6,647 | 0 | 80 | every use | untested | plan: none (grandfathered) |
| 30 | captain | skill | 6,522 | 7,856 | 127 | every use | untested | plan: none (grandfathered) |
| 31 | preamble | shared | 5,743 | n/a | n/a | every tier 2+ skill run, inside that skill's load | untested | plan: I1 ceremony cut (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-108-ceremony-cut-prereg (#122, main 409ff47) with its G3 amendment (#128), preset, Opus 5.5 and particle-1 (grandfathered) |
| 32 | gstack-upgrade | skill | 5,726 | 0 | 59 | every use | untested | plan: none (grandfathered) |
| 33 | context-restore | skill | 5,715 | 1,734 | 84 | every use | untested | plan: none (grandfathered) |
| 34 | document-generate | skill | 5,466 | 0 | 110 | every use | untested | plan: none (grandfathered) |
| 35 | ios-qa | skill | 5,368 | 1,378 | 84 | every use | untested | plan: none (grandfathered) |
| 36 | skillify | skill | 5,317 | 0 | 80 | every use | untested | plan: none (grandfathered) |
| 37 | capy-skill | shared | 5,309 | n/a | n/a | every Capy session | untested | plan: none (grandfathered) |
| 38 | health | skill | 5,287 | 0 | 68 | every use | untested | plan: none (grandfathered) |
| 39 | setup-deploy | skill | 5,216 | 0 | 73 | every use | untested | plan: none (grandfathered) |
| 40 | qa-only | skill | 5,205 | 3,460 | 68 | every use | untested | plan: none (grandfathered) |
| 41 | eval-plan | skill | 5,021 | 0 | 108 | every use | untested | plan: none (grandfathered) |
| 42 | gstack | skill | 4,951 | n/a | 59 | every use | untested | plan: none (grandfathered) |
| 43 | pair-agent | skill | 4,811 | 0 | 63 | every use | untested | plan: none (grandfathered) |
| 44 | make-pdf | skill | 4,730 | 1,767 | 65 | every use | untested | plan: none (grandfathered) |
| 45 | context-save | skill | 4,637 | 0 | 65 | every use | untested | plan: none (grandfathered) |
| 46 | cso | skill | 4,456 | 4,759 | 95 | every use | safety | reason: security: supported security findings with explicit coverage; kept on its security reason (GEVA-2 marks it exempt; GStack Minder may reclassify it) |
| 47 | open-gstack-browser | skill | 4,429 | 0 | 74 | every use | untested | plan: none (grandfathered) |
| 48 | design-motion | skill | 4,295 | 4,995 | 86 | every use | untested | plan: none (grandfathered) |
| 49 | test-audit | skill | 4,201 | 0 | 82 | every use | untested | plan: none (grandfathered) |
| 50 | document-release | skill | 4,049 | 10,260 | 72 | every use | untested | plan: none (grandfathered) |
| 51 | diagram | skill | 3,895 | 0 | 112 | every use | untested | plan: none (grandfathered) |
| 52 | benchmark-models | skill | 3,822 | 0 | 77 | every use | untested | plan: none (grandfathered) |
| 53 | ubicloud | skill | 3,593 | 0 | 83 | every use | untested | plan: none (grandfathered) |
| 54 | modernize | skill | 3,429 | 0 | 106 | every use | untested | plan: none (grandfathered) |
| 55 | setup-browser-cookies | skill | 3,184 | 0 | 79 | every use | untested | plan: none (grandfathered) |
| 56 | deslop-shared-libs | skill | 3,100 | 0 | 64 | every use | untested | plan: none (grandfathered) |
| 57 | landing-report | skill | 2,971 | 0 | 71 | every use | untested | plan: none (grandfathered) |
| 58 | ship-docs-audit | shared | 2,730 | n/a | n/a | every /ship run | untested | plan: folded into GEVA-2 registry row R56-4 (ship); proposed, not yet preregistered (grandfathered) |
| 59 | verify | skill | 2,679 | 0 | 91 | every use | untested | plan: none (grandfathered) |
| 60 | ios-design-review | skill | 2,367 | 0 | 76 | every use | untested | plan: none (grandfathered) |
| 61 | ios-sync | skill | 2,240 | 0 | 90 | every use | untested | plan: none (grandfathered) |
| 62 | ios-clean | skill | 2,214 | 0 | 86 | every use | untested | plan: none (grandfathered) |
| 63 | write-skill | skill | 2,158 | 795 | 100 | every use | untested | plan: none (grandfathered) |
| 64 | ios-fix | skill | 2,096 | 0 | 69 | every use | untested | plan: none (grandfathered) |
| 65 | learn | skill | 1,989 | 0 | 66 | every use | untested | plan: none (grandfathered) |
| 66 | careful | skill | 1,126 | 0 | 171 | every use | safety | reason: safety guard: warns before destructive commands (rm -rf, DROP TABLE, force-push); kept on that reason, an average score cannot see the rare bad run it stops |
| 67 | freeze | skill | 1,043 | 0 | 234 | every use | safety | reason: safety guard: hard-blocks edits outside one directory; kept on that reason |
| 68 | guard | skill | 908 | 0 | 348 | every use | safety | reason: safety guard: careful and freeze together; kept on that reason |
| 69 | capy-factory | shared | 846 | n/a | n/a | every Capy session | untested | plan: none (grandfathered) |
| 70 | unfreeze | skill | 372 | 0 | 65 | every use | safety | reason: safety guard: the off switch for freeze's edit restriction; kept with the guard it releases |
| 71 | claude-code | skill | n/a | n/a | n/a | every use | untested | plan: none (grandfathered) |
## Measure next
Untested parts, largest load per run first. A fair test runs the claude_code preset, frontier models (Opus 5.5 and GPT-6.1 Sol, plus at most one named low-cost primary) and a masked no-gstack arm ([TESTING_INTERNALS.md](TESTING_INTERNALS.md#no-gstack-arm)). Safety rows are not on this list.
| Order | Part | Load per run (tokens) | Plan |
|---|---|---|---|
| 1 | ship-review-panel | 22,830 | GEVA-2 registry row R56-1 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 2 | design-review | 20,159 | GEVA-2 registry row R56-9 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 3 | ship | 14,587 | GEVA-2 registry row R56-4 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 4 | plan-ceo-review | 14,394 | GEVA-2 registry row R56-5 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 5 | review | 13,990 | R2 (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-02-geva-122-r2-amendment (#129, main eae6efc), told /review vs a no-gstack arm, Opus 5.5, preset on both arms (grandfathered) |
| 6 | devex-review | 13,911 | GEVA-2 registry row R56-11 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 7 | plan-design-review | 13,351 | GEVA-2 registry row R56-7 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 8 | retro | 12,257 | GEVA-2 registry row R56-12 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 9 | plan-devex-review | 11,104 | GEVA-2 registry row R56-6 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 10 | autoplan-phases | 10,709 | folded into GEVA-2 registry row R56-3 (autoplan); proposed, not yet preregistered (grandfathered) |
| 11 | office-hours | 10,424 | none (grandfathered) |
| 12 | autoplan | 10,288 | GEVA-2 registry row R56-3 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 13 | plan-eng-review | 9,837 | GEVA-2 registry row R56-2 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 14 | design-consultation | 9,557 | GEVA-2 registry row R56-10 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 15 | setup-gbrain | 9,099 | none (grandfathered) |
| 16 | codex | 8,991 | none (grandfathered) |
| 17 | sync-gbrain | 8,863 | none (grandfathered) |
| 18 | design-shotgun | 8,470 | none (grandfathered) |
| 19 | plan-tune | 8,377 | none (grandfathered) |
| 20 | spec | 8,305 | none (grandfathered) |
| 21 | browse | 8,125 | none (grandfathered) |
| 22 | design-html | 8,096 | none (grandfathered) |
| 23 | land-and-deploy | 7,652 | GEVA-2 registry row R56-8 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered) (grandfathered) |
| 24 | benchmark | 7,430 | none (grandfathered) |
| 25 | catalog | 7,291 | Run D (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-125-untold-discovery-prereg (#125, main 9820b14), untold gstack vs placebo vs no gstack, preset vs stub, Opus 5.5, GPT-6.1 Sol and particle-1; also measures /investigate's description (grandfathered) |
| 26 | canary | 7,034 | none (grandfathered) |
| 27 | qa | 6,951 | none (grandfathered) |
| 28 | scrape | 6,647 | none (grandfathered) |
| 29 | captain | 6,522 | none (grandfathered) |
| 30 | preamble | 5,743 | I1 ceremony cut (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-108-ceremony-cut-prereg (#122, main 409ff47) with its G3 amendment (#128), preset, Opus 5.5 and particle-1 (grandfathered) |
| 31 | gstack-upgrade | 5,726 | none (grandfathered) |
| 32 | context-restore | 5,715 | none (grandfathered) |
| 33 | document-generate | 5,466 | none (grandfathered) |
| 34 | ios-qa | 5,368 | none (grandfathered) |
| 35 | skillify | 5,317 | none (grandfathered) |
| 36 | capy-skill | 5,309 | none (grandfathered) |
| 37 | health | 5,287 | none (grandfathered) |
| 38 | setup-deploy | 5,216 | none (grandfathered) |
| 39 | qa-only | 5,205 | none (grandfathered) |
| 40 | eval-plan | 5,021 | none (grandfathered) |
| 41 | gstack | 4,951 | none (grandfathered) |
| 42 | pair-agent | 4,811 | none (grandfathered) |
| 43 | make-pdf | 4,730 | none (grandfathered) |
| 44 | context-save | 4,637 | none (grandfathered) |
| 45 | open-gstack-browser | 4,429 | none (grandfathered) |
| 46 | design-motion | 4,295 | none (grandfathered) |
| 47 | test-audit | 4,201 | none (grandfathered) |
| 48 | document-release | 4,049 | none (grandfathered) |
| 49 | diagram | 3,895 | none (grandfathered) |
| 50 | benchmark-models | 3,822 | none (grandfathered) |
| 51 | ubicloud | 3,593 | none (grandfathered) |
| 52 | modernize | 3,429 | none (grandfathered) |
| 53 | setup-browser-cookies | 3,184 | none (grandfathered) |
| 54 | deslop-shared-libs | 3,100 | none (grandfathered) |
| 55 | landing-report | 2,971 | none (grandfathered) |
| 56 | ship-docs-audit | 2,730 | folded into GEVA-2 registry row R56-4 (ship); proposed, not yet preregistered (grandfathered) |
| 57 | verify | 2,679 | none (grandfathered) |
| 58 | ios-design-review | 2,367 | none (grandfathered) |
| 59 | ios-sync | 2,240 | none (grandfathered) |
| 60 | ios-clean | 2,214 | none (grandfathered) |
| 61 | write-skill | 2,158 | none (grandfathered) |
| 62 | ios-fix | 2,096 | none (grandfathered) |
| 63 | learn | 1,989 | none (grandfathered) |
| 64 | capy-factory | 846 | none (grandfathered) |
| 65 | claude-code | n/a | none (grandfathered) |
## Shared parts and preamble tiers
| Tier | Skills | What loads |
|---|---|---|
| 1 | benchmark, benchmark-models, browse, diagram, gstack, make-pdf, open-gstack-browser, scrape, setup-browser-cookies, verify | a short preamble inline in SKILL.md |
| 2 | canary, captain, context-restore, context-save, design-html, design-motion, design-shotgun, document-generate, document-release, eval-plan, health, investigate, ios-clean, ios-design-review, ios-fix, ios-sync, landing-report, learn, modernize, pair-agent, plan-tune, retro, setup-deploy, setup-gbrain, skillify, sync-gbrain, test-audit, ubicloud, write-skill | the shared sections/preamble.md (row preamble), once per session |
| 3 | autoplan, claude-code, codex, design-consultation, devex-review, ios-qa, office-hours, plan-ceo-review, plan-design-review, plan-devex-review, plan-eng-review, spec | the shared sections/preamble.md (row preamble), once per session |
| 4 | design-review, land-and-deploy, qa, qa-only, review, ship | the shared sections/preamble.md (row preamble), once per session |
| none | careful, cso, deslop-shared-libs, freeze, gstack-upgrade, guard, unfreeze | no preamble |
- **preamble**: shared preamble (sections/preamble.md, identical in every tier 2+ skill) (ship/sections/preamble.md); loads every tier 2+ skill run, inside that skill's load.
- **catalog**: skill catalog (every skill's frontmatter); loads every session.
- **capy-skill**: capy/skill/gstack/SKILL.md (capy/skill/gstack/SKILL.md); loads every Capy session.
- **capy-factory**: capy/skill/gstack/factory.md (capy/skill/gstack/factory.md); loads every Capy session.
- **ship-review-panel**: /ship review panel (review-army and adversarial sections) (ship/sections/review-army.md, ship/sections/adversarial.md); loads every /ship run, by depth.
- **ship-docs-audit**: /ship docs audit (Step 14.5) (ship/sections/documentation.md); loads every /ship run.
- **autoplan-phases**: /autoplan CEO, design, DX and eng phases (autoplan/sections/ceo-phase.md, autoplan/sections/design-phase.md, autoplan/sections/dx-phase.md, autoplan/sections/eng-phase.md); loads every /autoplan run, by scope.
## Paid dispatches per run
Each count is quoted from the file and line named; `bin/gstack-inventory --check` fails when a quoted line moves away.
| Skill | What | Count | Source |
|---|---|---|---|
| ship | Step 9 review, pass 1, light depth, docs or tests only | 1 reader (the core checklist) | docs/review-policy.md:194 |
| ship | Step 9 review, pass 1, light depth, code | 3 readers (core, one batched specialist, native adversarial), plus the outside voice on a diff that publishes numbers | docs/review-policy.md:195 |
| ship | Step 9 review, pass 1, standard depth | the batched specialist, the Red Team at 200 runtime lines, adversarial and outside voice | docs/review-policy.md:196 |
| ship | Step 9 review, pass 1, full depth | the batched specialist on the whole diff and the Red Team always | docs/review-policy.md:197 |
| ship | targeted reader on money, gate, auth or secret code | +1 at every depth | docs/review-policy.md:319 |
| ship | audits (Steps 7, 8 and 14.5) | 3 | lib/ship-budget-calibration.json:3 |
| ship | measured: one /ship child (gstack-evals#31) | 10 review dispatches | lib/ship-budget-calibration.json:43 |
| ship | prior cost of one dispatch on the Capy meter | 7,430,000 tokens | lib/ship-budget-calibration.json:56 |
| autoplan | voices per phase (native and outside) | 2 | autoplan/DRIVER.md.tmpl:25 |
| autoplan | phases per round: CEO and Eng always, Design with UI scope, DX with developer-facing scope | 2 to 4, so 4 to 8 voice dispatches a round | autoplan/SKILL.md.tmpl:99 |
| autoplan | CEO spec review loop | up to 3 launches | autoplan/sections/ceo-phase.md.tmpl:50 |
| autoplan | review rounds per phase (new unattended runs) | up to 2 | lib/autoplan-budget.ts:23 |
| autoplan | reserved per voice on Capy or under a spend cap | $5 | lib/autoplan-budget.ts:25 |
## Earlier results that do not count yet
- `stub`: ran on the Agent SDK stub prompt, not the claude_code preset.
- `small-tier`: Haiku, Sonnet or another non-frontier model only.
- `unmasked`: no mask recorded: the no-gstack arm predates the masking rule and was not re-checked.
- `ceiling`: control at the ceiling, so no difference could show.
- `no-true-no-gstack-arm`: the none arm still had gstack installed.
- `no-exposure`: the model never used the part.
- `not-claude-code`: ran outside Claude Code (Messages API, Codex or Capy).
- `variant-only`: variant against variant, no no-gstack arm.
| Part | Result | Why it does not count | Note |
|---|---|---|---|
| investigate | gstack-evals #109 stacks round 1 TB2 (GEVA-89, GEVA-93), wiki/board/2026-11-01-geva-93-stacks-round-1-tb2-results | stub, no-exposure, unmasked | Opus +3 points [-3, +9] at 1.9x cost; debug report in 0 of 111 trials; particle-1 -5 [-14, +1] |
| investigate | GSTA-12 Lane B quick look 2, wiki/board/2026-10-29-gsta-12-laneb-quick-2-results | stub, unmasked | Opus gstack minus none -7 [-13, -2] at 1.7x cost; did not replicate |
| investigate | GSTA-30 Lane B quick look 3 (#89), wiki/board/2026-10-30-gsta-30-laneb-quick-3-results | stub, small-tier | gstack arms on Sonnet, particle-1, DeepSeek and Haiku; Opus ran the none arm only |
| investigate | HARN-14 replication (harness-evals #181, unmerged) | stub, small-tier | Haiku 5.5: gstack minus none +3.5 [-12.3, +20.0] |
| learn | GSTA-13, GEVA-54 and GEVA-65 Lane A runs (wiki/board/2026-10-28-gsta-13-quick-look-3-results and the GEVA-54 and GEVA-65 posts) | stub, no-true-no-gstack-arm, ceiling | Opus contract tasks 27/30 vs 27/30 |
| learn | GEVA-65 Lane A rerun row 5 (GPT-6.1 Sol in Codex) | not-claude-code, no-true-no-gstack-arm | Sol failed the no-code-change safety task in 5 of 9 skill trials vs 0 of 3 without; small n, a lead to check, not a hurts verdict |
| office-hours | GSTA-13 quick look 3 Lane A, wiki/board/2026-10-28-gsta-13-quick-look-3-results | stub, no-true-no-gstack-arm | contract tasks on Opus: gstack 74% vs none 28%; the tasks grade the skill's own contract, a manipulation check, not lift |
| office-hours | GEVA-65 Lane A rerun, wiki/board/2026-10-31-geva-65-lanea-shakedown-rerun-results | stub, no-true-no-gstack-arm | Opus before 0.67, after 0.76, none 0.36 |
| plan-ceo-review | CEO panel v2 admission (GEVA-142), wiki/board/2026-11-02-geva-142-ceo-panel-v2-admission-results (open PR #131) | ceiling, unmasked | preset, Opus 5.5 only: must-fix recall 1.00 in all three arms; plain invocation ran the procedure in 0 of 16 |
| plan-ceo-review | Planning R1a pilot (GEVA-102), wiki/board/2026-11-01-geva-102-planning-r1a-pilot-stop (#114) | stub, ceiling, unmasked | none-opus 8/8; gstack-opus read the sections file in 0 of 4 |
| review | gstack-evals #108 stacks round 1 review (GEVA-89), wiki/board/2026-11-01-geva-89-stacks-round-1-review-results | stub, no-exposure, unmasked | Opus 5.5 blocking recall 9.5% vs 7.1%, +2.4 points [-6.4, +11.1]; specialists and adversarial pass ran in 0 of 66 sessions |
| review | gstack-evals #120 R1 blind re-judge (GEVA-113), wiki/board/2026-11-01-geva-113-r1-blind-rejudge-results | stub, unmasked | gstack-opus minus none-opus +3.5 points [-9.6, +16.7] |
| review | GSTA-11 quick look 1 review replay, wiki/board/2026-10-28-gsta-11-review-replay-results | not-claude-code, unmasked | /review's prompts through the Messages API; Opus recall 44% vs 39% plain, 12 bugs |
| preamble | GEVA-100 exposure counts, org-gstack-gbrain/research/why-null-stacks-r1.md (GEVA-100) | stub, no-exposure | ceremony sections ran in 0 of 66 Opus review sessions and 6 of 111 Opus TB2 trials; no effect estimate |
| catalog | GSTA-12 Lane B quick look 1, wiki/board/2026-10-29-gsta-12-laneb-quick-results | stub, no-exposure, unmasked | untold skill opened in 0 of 180 gstack-arm trials; Opus gstack minus none +5 [-3, +17] |
| ship-review-panel | gstack-evals #120 R1 blind re-judge (GEVA-113), wiki/board/2026-11-01-geva-113-r1-blind-rejudge-results | stub, not-claude-code, unmasked | arm C minus none +56.9 points [40.5, 72.2]; the none arm ran on the stub, arm C in Capy at another gstack version, on a cohort mined from gstack's own catches |
| ship-review-panel | GSTA-48 review no-loss arm D (#77), wiki/board/2026-10-30-gsta-48-review-noloss-arm-how-to | variant-only, not-claude-code | arm D vs pooled arm C, no no-gstack arm |
## Key list and aliases
71 keys: 64 skills (the folders `discoverTemplates()` finds, the root skill as `gstack`) and 7 shared parts.
- Skills: autoplan, benchmark, benchmark-models, browse, canary, captain, careful, claude-code, codex, context-restore, context-save, cso, design-consultation, design-html, design-motion, design-review, design-shotgun, deslop-shared-libs, devex-review, diagram, document-generate, document-release, eval-plan, freeze, gstack, gstack-upgrade, guard, health, investigate, ios-clean, ios-design-review, ios-fix, ios-qa, ios-sync, land-and-deploy, landing-report, learn, make-pdf, modernize, office-hours, open-gstack-browser, pair-agent, plan-ceo-review, plan-design-review, plan-devex-review, plan-eng-review, plan-tune, qa, qa-only, retro, review, scrape, setup-browser-cookies, setup-deploy, setup-gbrain, ship, skillify, spec, sync-gbrain, test-audit, ubicloud, unfreeze, verify, write-skill.
- Shared: preamble, catalog, capy-skill, capy-factory, ship-review-panel, ship-docs-audit, autoplan-phases.
- Installed names: `gstack-<name>` is key `<name>` (`gstack-ship` is `ship`); `gstack-upgrade` keeps its name; the root skill installs as `gstack`.
- Other names (the ledger's `aliases`): gstack (root skill) → gstack; upgrade → gstack-upgrade; preamble (ceremony) → preamble; always-on skill catalog (descriptions) → catalog; capy/skill/gstack/SKILL.md (Capy context) → capy-skill; capy/skill/gstack/factory.md (Capy context) → capy-factory; ship: review panel (specialist, adversarial, targeted, outside voice, red team) → ship-review-panel; ship: docs audit (Step 14.5) → ship-docs-audit; autoplan: CEO, design, DX and eng phases, outside voice → autoplan-phases.
## Not in this inventory
- Out of scope: openclaw/skills/* and browser-skills/* (not gstack skills an agent loads from this checkout), and connect-chrome (a symlink to open-gstack-browser, counted once).
- Evidence-table row "review: Review Army and adversarial pass": a section inside /review, not its own key; R2 measures it as part of the review row's plan.
- Evidence-table row "ship: QA probes (Step 9.2.1)": a step inside /ship with no separate size; folded into the ship row (GEVA-2 registry row R56-4).
- Evidence-table row "gstack as a whole (installed, any skill)": not a single text part; GEVA-2's real-world outcome lane measures it (gstack-evals plan #111), not a measurement plan for any one part.
+661
View File
@@ -0,0 +1,661 @@
{
"schema_version": 1,
"note": "R56 evidence ledger (ETHOS.md: 9. Earn Its Place). One row per inventory key (docs/inventory.md lists them); bin/gstack-inventory --check validates it and test/inventory.test.ts runs that check. Statuses and their required fields: helps, no-effect, hurts need sources, models (frontier per R54), prompt: preset, scope and masked: true, and no-effect and hurts also r48 {verdict, ref}; untested needs plan (a reference, or none only when grandfathered), and a new part also reason, owner and measure_by; safety needs reason. prior lists earlier results that do not count yet, with why. Seeded 2026-11-02 from GEVA-2's table (org-gstack-gbrain/research/gstack-part-evidence.json, gstack dac71371a); every row present at that base is grandfathered.",
"seed_source": "org-gstack-gbrain/research/gstack-part-evidence.json (GEVA-156 for GST-915, 2026-11-02, gstack-evals main 409ff47)",
"parts": {
"autoplan": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-3 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: autoplan: untested (never measured)"
},
"benchmark": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: benchmark: untested (never measured)"
},
"benchmark-models": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: benchmark-models: untested (never measured)"
},
"browse": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: browse: untested (never measured)"
},
"canary": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: canary: untested (never measured)"
},
"captain": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: captain: untested (never measured)"
},
"careful": {
"status": "safety",
"grandfathered": true,
"reason": "safety guard: warns before destructive commands (rm -rf, DROP TABLE, force-push); kept on that reason, an average score cannot see the rare bad run it stops",
"seed": "GEVA-2, 2026-11-02: careful: exempt (safety guard)"
},
"claude-code": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: claude-code: untested (never measured)"
},
"codex": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "not in GEVA-2's table (2026-11-02); never measured"
},
"context-restore": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: context-restore: untested (never measured)"
},
"context-save": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: context-save: untested (never measured)"
},
"cso": {
"status": "safety",
"grandfathered": true,
"reason": "security: supported security findings with explicit coverage; kept on its security reason (GEVA-2 marks it exempt; GStack Minder may reclassify it)",
"seed": "GEVA-2, 2026-11-02: cso: exempt (security audit skill, treated as exempt; GStack Minder may reclassify)"
},
"design-consultation": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-10 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: design-consultation: untested (never measured)"
},
"design-html": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: design-html: untested (never measured)"
},
"design-motion": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: design-motion: untested (never measured)"
},
"design-review": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-9 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: design-review: untested (never measured)"
},
"design-shotgun": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: design-shotgun: untested (never measured)"
},
"deslop-shared-libs": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: deslop-shared-libs: untested (never measured)"
},
"devex-review": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-11 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: devex-review: untested (never measured)"
},
"diagram": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: diagram: untested (never measured)"
},
"document-generate": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: document-generate: untested (never measured)"
},
"document-release": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: document-release: untested (never measured)"
},
"eval-plan": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: eval-plan: untested (never measured)"
},
"freeze": {
"status": "safety",
"grandfathered": true,
"reason": "safety guard: hard-blocks edits outside one directory; kept on that reason",
"seed": "GEVA-2, 2026-11-02: freeze: exempt (safety guard)"
},
"gstack": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: gstack (root skill): untested (never measured)",
"aliases": [
"gstack (root skill)"
]
},
"gstack-upgrade": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: upgrade: untested (never measured)",
"aliases": [
"upgrade"
]
},
"guard": {
"status": "safety",
"grandfathered": true,
"reason": "safety guard: careful and freeze together; kept on that reason",
"seed": "GEVA-2, 2026-11-02: guard: exempt (safety guard)"
},
"health": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: health: untested (never measured)"
},
"investigate": {
"status": "no-effect",
"grandfathered": true,
"sources": [
"GST-814 (HARN-1, 2026-11-02)",
"org-gstack-gbrain handoffs/HARN-14/hard-follow-2026-11/decision-rule.md"
],
"models": [
"claude-opus-5-5"
],
"prompt": "preset",
"scope": "skill step procedure follow rate: 0 of 46 gstack sessions ran any step (0 of 30 on the claude_code preset); body loaded every time; the outcome check voided at ceiling (no-gstack fixed 8 of 8)",
"masked": true,
"r48": {
"verdict": "CUT-CANDIDATE",
"ref": "GST-938 (GSTA-251 plans hooks-or-drop)"
},
"plan": "R3 (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-101-r48-follow-ups-prereg (#112, main f0b81c0) and its amendment (open PR #133), told /investigate vs no gstack on SWE-bench Verified, preset, Opus 5.5 then GPT-6.1 Sol",
"prior": [
{
"ref": "gstack-evals #109 stacks round 1 TB2 (GEVA-89, GEVA-93), wiki/board/2026-11-01-geva-93-stacks-round-1-tb2-results",
"why": [
"stub",
"no-exposure",
"unmasked"
],
"note": "Opus +3 points [-3, +9] at 1.9x cost; debug report in 0 of 111 trials; particle-1 -5 [-14, +1]"
},
{
"ref": "GSTA-12 Lane B quick look 2, wiki/board/2026-10-29-gsta-12-laneb-quick-2-results",
"why": [
"stub",
"unmasked"
],
"note": "Opus gstack minus none -7 [-13, -2] at 1.7x cost; did not replicate"
},
{
"ref": "GSTA-30 Lane B quick look 3 (#89), wiki/board/2026-10-30-gsta-30-laneb-quick-3-results",
"why": [
"stub",
"small-tier"
],
"note": "gstack arms on Sonnet, particle-1, DeepSeek and Haiku; Opus ran the none arm only"
},
{
"ref": "HARN-14 replication (harness-evals #181, unmerged)",
"why": [
"stub",
"small-tier"
],
"note": "Haiku 5.5: gstack minus none +3.5 [-12.3, +20.0]"
}
],
"seed": "GEVA-2, 2026-11-02: investigate: measurement under way (R3)",
"note": "Only Opus 5.5 (claude-opus-5-5) was measured; GPT-6.1 Sol was not, so this verdict covers one of the two frontier models ETHOS.md 9 names."
},
"ios-clean": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: ios-clean: untested (never measured)"
},
"ios-design-review": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: ios-design-review: untested (never measured)"
},
"ios-fix": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: ios-fix: untested (never measured)"
},
"ios-qa": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: ios-qa: untested (never measured)"
},
"ios-sync": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: ios-sync: untested (never measured)"
},
"land-and-deploy": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-8 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: land-and-deploy: untested (never measured)"
},
"landing-report": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: landing-report: untested (never measured)"
},
"learn": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"prior": [
{
"ref": "GSTA-13, GEVA-54 and GEVA-65 Lane A runs (wiki/board/2026-10-28-gsta-13-quick-look-3-results and the GEVA-54 and GEVA-65 posts)",
"why": [
"stub",
"no-true-no-gstack-arm",
"ceiling"
],
"note": "Opus contract tasks 27/30 vs 27/30"
},
{
"ref": "GEVA-65 Lane A rerun row 5 (GPT-6.1 Sol in Codex)",
"why": [
"not-claude-code",
"no-true-no-gstack-arm"
],
"note": "Sol failed the no-code-change safety task in 5 of 9 skill trials vs 0 of 3 without; small n, a lead to check, not a hurts verdict"
}
],
"seed": "GEVA-2, 2026-11-02: learn: untested (unfair test: SDK stub; no true no-gstack arm; Opus at ceiling; Sol ran in Codex, not Claude Code)"
},
"make-pdf": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: make-pdf: untested (never measured)"
},
"modernize": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: modernize: untested (never measured)"
},
"office-hours": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"prior": [
{
"ref": "GSTA-13 quick look 3 Lane A, wiki/board/2026-10-28-gsta-13-quick-look-3-results",
"why": [
"stub",
"no-true-no-gstack-arm"
],
"note": "contract tasks on Opus: gstack 74% vs none 28%; the tasks grade the skill's own contract, a manipulation check, not lift"
},
{
"ref": "GEVA-65 Lane A rerun, wiki/board/2026-10-31-geva-65-lanea-shakedown-rerun-results",
"why": [
"stub",
"no-true-no-gstack-arm"
],
"note": "Opus before 0.67, after 0.76, none 0.36"
}
],
"seed": "GEVA-2, 2026-11-02: office-hours: untested (unfair test: SDK stub; no true no-gstack arm; tasks grade the skill's own contract, a manipulation check, not lift)"
},
"open-gstack-browser": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: open-gstack-browser: untested (never measured)"
},
"pair-agent": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: pair-agent: untested (never measured)"
},
"plan-ceo-review": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-5 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"prior": [
{
"ref": "CEO panel v2 admission (GEVA-142), wiki/board/2026-11-02-geva-142-ceo-panel-v2-admission-results (open PR #131)",
"why": [
"ceiling",
"unmasked"
],
"note": "preset, Opus 5.5 only: must-fix recall 1.00 in all three arms; plain invocation ran the procedure in 0 of 16"
},
{
"ref": "Planning R1a pilot (GEVA-102), wiki/board/2026-11-01-geva-102-planning-r1a-pilot-stop (#114)",
"why": [
"stub",
"ceiling",
"unmasked"
],
"note": "none-opus 8/8; gstack-opus read the sections file in 0 of 4"
}
],
"r48": {
"verdict": "FIX-METHOD",
"ref": "CEO panel v2 admission (GEVA-142, open PR #131): FIX-METHOD on the panel, a harder panel is planned"
},
"seed": "GEVA-2, 2026-11-02: plan-ceo-review: untested (unfair test: ceiling, control 12 of 12 so no MDD; plain invocation ran the procedure in 0 of 16)"
},
"plan-design-review": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-7 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: plan-design-review: untested (never measured)"
},
"plan-devex-review": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-6 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: plan-devex-review: untested (never measured)"
},
"plan-eng-review": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-2 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: plan-eng-review: untested (never measured)"
},
"plan-tune": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: plan-tune: untested (never measured)"
},
"qa": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: qa: untested (never measured)"
},
"qa-only": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: qa-only: untested (never measured)"
},
"retro": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-12 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: retro: untested (never measured)"
},
"review": {
"status": "untested",
"grandfathered": true,
"plan": "R2 (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-02-geva-122-r2-amendment (#129, main eae6efc), told /review vs a no-gstack arm, Opus 5.5, preset on both arms",
"prior": [
{
"ref": "gstack-evals #108 stacks round 1 review (GEVA-89), wiki/board/2026-11-01-geva-89-stacks-round-1-review-results",
"why": [
"stub",
"no-exposure",
"unmasked"
],
"note": "Opus 5.5 blocking recall 9.5% vs 7.1%, +2.4 points [-6.4, +11.1]; specialists and adversarial pass ran in 0 of 66 sessions"
},
{
"ref": "gstack-evals #120 R1 blind re-judge (GEVA-113), wiki/board/2026-11-01-geva-113-r1-blind-rejudge-results",
"why": [
"stub",
"unmasked"
],
"note": "gstack-opus minus none-opus +3.5 points [-9.6, +16.7]"
},
{
"ref": "GSTA-11 quick look 1 review replay, wiki/board/2026-10-28-gsta-11-review-replay-results",
"why": [
"not-claude-code",
"unmasked"
],
"note": "/review's prompts through the Messages API; Opus recall 44% vs 39% plain, 12 bugs"
}
],
"r48": {
"verdict": "FIX-METHOD",
"ref": "org-gstack-gbrain/research/why-null-stacks-r1.md (GEVA-100); R2 is the fix"
},
"seed": "GEVA-2, 2026-11-02: review: measurement under way (R2)"
},
"scrape": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: scrape: untested (never measured)"
},
"setup-browser-cookies": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: setup-browser-cookies: untested (never measured)"
},
"setup-deploy": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: setup-deploy: untested (never measured)"
},
"setup-gbrain": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: setup-gbrain: untested (never measured)"
},
"ship": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-4 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"seed": "GEVA-2, 2026-11-02: ship: untested (never measured)"
},
"skillify": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: skillify: untested (never measured)"
},
"spec": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: spec: untested (never measured)"
},
"sync-gbrain": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: sync-gbrain: untested (never measured)"
},
"test-audit": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: test-audit: untested (never measured)"
},
"ubicloud": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: ubicloud: untested (never measured)"
},
"unfreeze": {
"status": "safety",
"grandfathered": true,
"reason": "safety guard: the off switch for freeze's edit restriction; kept with the guard it releases",
"seed": "GEVA-2, 2026-11-02: unfreeze: exempt (safety guard (undoes freeze))"
},
"verify": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "not in GEVA-2's table (2026-11-02); never measured"
},
"write-skill": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: write-skill: untested (never measured)"
},
"preamble": {
"status": "untested",
"grandfathered": true,
"plan": "I1 ceremony cut (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-108-ceremony-cut-prereg (#122, main 409ff47) with its G3 amendment (#128), preset, Opus 5.5 and particle-1",
"prior": [
{
"ref": "GEVA-100 exposure counts, org-gstack-gbrain/research/why-null-stacks-r1.md (GEVA-100)",
"why": [
"stub",
"no-exposure"
],
"note": "ceremony sections ran in 0 of 66 Opus review sessions and 6 of 111 Opus TB2 trials; no effect estimate"
}
],
"r48": {
"verdict": "CUT-CANDIDATE",
"ref": "org-gstack-gbrain/research/why-null-stacks-r1.md (GEVA-100); taken by Garry as the I1 ceremony-cut eval"
},
"seed": "GEVA-2, 2026-11-02: preamble (ceremony): measurement under way (I1, ceremony-cut eval)",
"aliases": [
"preamble (ceremony)"
]
},
"catalog": {
"status": "untested",
"grandfathered": true,
"plan": "Run D (GEVA-2, 2026-11-02): gstack-evals wiki/plans/2026-11-01-geva-125-untold-discovery-prereg (#125, main 9820b14), untold gstack vs placebo vs no gstack, preset vs stub, Opus 5.5, GPT-6.1 Sol and particle-1; also measures /investigate's description",
"prior": [
{
"ref": "GSTA-12 Lane B quick look 1, wiki/board/2026-10-29-gsta-12-laneb-quick-results",
"why": [
"stub",
"no-exposure",
"unmasked"
],
"note": "untold skill opened in 0 of 180 gstack-arm trials; Opus gstack minus none +5 [-3, +17]"
}
],
"seed": "GEVA-2, 2026-11-02: always-on skill catalog (descriptions): measurement under way (Run D)",
"aliases": [
"always-on skill catalog (descriptions)"
]
},
"capy-skill": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: capy/skill/gstack/SKILL.md (Capy context): untested (never measured)",
"aliases": [
"capy/skill/gstack/SKILL.md (Capy context)"
],
"note": "loads only in Capy sessions, which the claude_code preset fair test cannot reach yet; needs a Captain call on how to test it"
},
"capy-factory": {
"status": "untested",
"grandfathered": true,
"plan": "none",
"seed": "GEVA-2, 2026-11-02: capy/skill/gstack/factory.md (Capy context): untested (never measured)",
"aliases": [
"capy/skill/gstack/factory.md (Capy context)"
],
"note": "loads only in Capy sessions, which the claude_code preset fair test cannot reach yet; needs a Captain call on how to test it"
},
"ship-review-panel": {
"status": "untested",
"grandfathered": true,
"plan": "GEVA-2 registry row R56-1 (org-gstack-gbrain/research/gstack-part-evidence.json registry_rows; proposed, not yet preregistered)",
"prior": [
{
"ref": "gstack-evals #120 R1 blind re-judge (GEVA-113), wiki/board/2026-11-01-geva-113-r1-blind-rejudge-results",
"why": [
"stub",
"not-claude-code",
"unmasked"
],
"note": "arm C minus none +56.9 points [40.5, 72.2]; the none arm ran on the stub, arm C in Capy at another gstack version, on a cohort mined from gstack's own catches"
},
{
"ref": "GSTA-48 review no-loss arm D (#77), wiki/board/2026-10-30-gsta-48-review-noloss-arm-how-to",
"why": [
"variant-only",
"not-claude-code"
],
"note": "arm D vs pooled arm C, no no-gstack arm"
}
],
"seed": "GEVA-2, 2026-11-02: ship: review panel (specialist, adversarial, targeted, outside voice, red team): untested (unfair test: none arm on the SDK stub; arms from different harnesses and gstack versions; cohort mined from gstack's own catches)",
"aliases": [
"ship: review panel (specialist, adversarial, targeted, outside voice, red team)"
]
},
"ship-docs-audit": {
"status": "untested",
"grandfathered": true,
"plan": "folded into GEVA-2 registry row R56-4 (ship); proposed, not yet preregistered",
"seed": "GEVA-2, 2026-11-02: ship: docs audit (Step 14.5): untested (never measured)",
"aliases": [
"ship: docs audit (Step 14.5)"
]
},
"autoplan-phases": {
"status": "untested",
"grandfathered": true,
"plan": "folded into GEVA-2 registry row R56-3 (autoplan); proposed, not yet preregistered",
"seed": "GEVA-2, 2026-11-02: autoplan: CEO, design, DX and eng phases, outside voice: untested (never measured)",
"aliases": [
"autoplan: CEO, design, DX and eng phases, outside voice"
]
}
},
"unmapped": [
{
"row": "review: Review Army and adversarial pass",
"why": "a section inside /review, not its own key; R2 measures it as part of the review row's plan"
},
{
"row": "ship: QA probes (Step 9.2.1)",
"why": "a step inside /ship with no separate size; folded into the ship row (GEVA-2 registry row R56-4)"
},
{
"row": "gstack as a whole (installed, any skill)",
"why": "not a single text part; GEVA-2's real-world outcome lane measures it (gstack-evals plan #111), not a measurement plan for any one part"
}
]
}
+69 -8
View File
@@ -86,7 +86,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` forwards its own `--timeout`; without it, and in the `finish` fallback, this is how a repo with long lanes sets theirs |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os?, skippable? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
@@ -173,7 +173,7 @@ The list lives in its own file, not in `pregate.json`, because older pregate rea
## `gstack-pregate`
```
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
gstack-pregate [--stage preflight|tests|guards|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
```
@@ -765,10 +765,12 @@ once:
such as `"per_file": "bun test --timeout=30000 {files}"`, the same form as the
tests stage's `runner`). That run is recorded under `<label>.skipped`, the
`LANE:` line adds `skipped_here=<n> file(s) tests_ran_here=<n>`, and the CI row
is written only after it passes with a test count above zero and every carried
run still stands (`run_changed` otherwise). A failure there is
`skipped_failed` and a run of zero tests is `skipped_zero_run`; either way the
whole lane runs here. An entry with no `per_file` refuses as
is written only after four checks pass. In order, they are the remainder's
exit (`skipped_failed`), its test count above zero (`skipped_zero_run`), every
carried run again (`run_changed`), and the working tree once more
(`tree_changed`, for an edit made during those checks). An edit made while the
remainder itself runs leaves its ledger row without a tree, so it shows as
`skipped_zero_run`. Any of these refusals runs the whole lane here. An entry with no `per_file` refuses as
`inventory_skipped`, naming the files, and the lane runs here whole. A partly
skipped file ran tests and stays covered. gstack's own Free Tests writer does
not emit the list yet: it starts in the same follow-up pull request that
@@ -1014,12 +1016,27 @@ a branch that does not contain the base tip it tested another tree and
`CI_CARRY` says `none (no_verified_run: … tree_mismatch)`: dispatch Free Tests
on the branch
(`gh workflow run free-tests.yml --ref <branch>`), which tests HEAD's own tree,
and wait again. The preflight stage (generated-file freshness, secrets, strays
and wait again. When the pull_request run tested that merge commit, the
refusal names the command for the run's branch (`fix: gh workflow run
free-tests.yml --ref <branch>, then gstack-ci-carry wait`). The preflight stage (generated-file freshness, secrets, strays
and the other mechanical checks) and the sealed guard are unchanged and always
run here. The macOS and Windows lane
obligations still clear with `gstack-pregate clear --remote <lane> --run-url
<url>` from their CI runs.
<a id="dispatch-carry"></a>**A dispatched run can hide a red merge commit.** A
`workflow_dispatch` run reports its checks on the head commit under the same
job names as the `pull_request` run. So a green dispatch run of HEAD's own tree
can stand in for a required check whose `pull_request` run failed on the merge
commit, and without strict branch
protection ("Require branches to be up to date before merging") the pull
request can merge with that failure hidden (GBEV-3). Turn on strict protection
for the base branch, or give dispatch runs distinct job names (for example a
job `name:` that includes `github.event_name`), so a dispatch run never stands
in for the required `pull_request` check. gstack's own merge trains test the
merged tree before they merge, so gstack is covered; a repo that merges
without a train is not.
**/ship lanes: `ship_lanes_from`.** A lane carries only when the base branch's
`.gstack/pregate.json` maps its exact command (the evidence ledger binds the
command's sha256; a label alone never carries) to the profiles whose runs carry
@@ -1123,6 +1140,9 @@ As a second guard, every `record-ci` row names the policy that mapped it
(`origin/<base>@<sha>:.gstack/pregate.json`), and `gstack-evidence carried`, which
the ship receipt reads, lists only rows whose policy is `origin/<the receipt's
base>`. A row written by a run against another base never lands in a receipt.
`carried` also lists a row only when `check`'s own grading, at the 24-hour age
limit /ship uses, calls it FRESH, so a carry that never reached FRESH (a failed
or zero-run row, or one older than 24 hours) leaves no `ci_carried` entry.
A carried lane prints `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`
(see above for what `<n>` counts) and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
@@ -1131,7 +1151,11 @@ itself, so no caller hands it run ids or a test count: `source: "ci"`, its runs
and profiles, the run's finish time as `ts`, bound to the clean tree), never a
fictitious local run. `gstack-evidence check` grades it
like any row and names `source=ci runs=…`, so `finish`'s FRESH check and the
24-hour age limit apply to the CI run's own time. A mapped lane that cannot
24-hour age limit apply to the CI run's own time. The ledger stores a label
with every character outside `A-Za-z0-9._-` turned into `_` (`pnpm lint` is
`pnpm_lint`, `db:test` is `db_test`), and `run`, `record-ci` and `check` all
use that one spelling, so `check --label 'pnpm lint'` finds the row; `ci_carried`
and `CI_CARRIED:` name the stored spelling. A mapped lane that cannot
carry prints `CI_CARRY: none label=<label> (<reason>)` and runs here. Every
other lane runs through `gstack-evidence run --reuse`: the newest row for its
label, command and working tree is reused when it passed with a counted run
@@ -1305,6 +1329,43 @@ guard is a *command* with its own fix line. Older gstack refuses unknown keys
that reads them ships. The worked gbrain configuration is in
[gbrain worked configs](#gbrain-worked-configs).
<a id="prepush"></a>
### Before every push: `--stage guards`
Housekeeping checks cause a large share of red CI runs: a stale render or
golden, a size ratchet, a token budget, a type error. gbrain had 64 of 93 red
heads from these (FOUN-64), and gstack's #3908 went red on the context-budget
ratchet. Each one is quick to run locally, so run them before the push instead
of waiting a CI round to find out:
```
gstack-pregate --stage guards --allow-repo-commands
```
`--stage guards` runs only two checks: `regen` (generated-file freshness) and
`guards`. Each runs only for the files the branch touched, so a docs-only
change runs nothing. It writes `.gstack/tmp/pregate-guards.json`, so it never
replaces the full run's `.gstack/tmp/pregate.json`. It refuses `--only`,
`--remote` and `--from-run`.
`gstack-ship-release push` runs it before every push, early or final, and
prints `GUARDS: <verdict> (<detail>)`. A failing or unfinished guard stops the
push with the guard's fix, and nothing is pushed. A pass already recorded at
HEAD's exact tree is reused instead of rerun: either push's own record or
`finish`'s preflight record. Both live under `.git`, where no commit can
place one. The guards must have checked the tree being pushed, so uncommitted
or untracked files that change what they saw stop the push until you commit or
stash them. A refused run (an unreadable policy, a base that is on origin but
not fetched, a mistyped `--base`) stops the push too; only a remote that has no branch except the one
being pushed skips the guards, as `GUARDS: not run`. Outside /ship, run the
command above yourself before `git push`.
gstack declares its own list in `.gstack/pregate.json`: `typecheck`,
`skill-size`, `parity-suite`, `context-budget`, `module-size`, `goldens` and
`doc-caps`. Generated SKILL.md freshness is the `regen` check's job. Other
repos add their own `guards` the same way; the key needs no newer gstack than
the one that already reads `guards`.
<a id="guard-carry"></a>
### A guard that already passed at this tree does not run again
+48 -3
View File
@@ -182,6 +182,46 @@ reads everything), one `SKIP:` per reviewer left out, and `--json` with the same
base as `base`).
A plan that fails closed to full exits 0; a diff git cannot list exits 1.
### Who reads (R65, the base policy's `review` key)
The depth says how much review a diff needs; the base branch's `.gstack/ship-policy.json`
`review` key says who does it (`review.panel`, default `all`: today's panel on every class). `gstack-review-plan`
prints `REVIEW_MODE: <lean|panel|platform> panel=<p> reader_model=<m> platform=<on|off>`
and, under `review.platform on`, `PLATFORM_REVIEW: <id> reviewer=capy-ai[bot] head=<sha>`
or `PLATFORM_REVIEW: none (<reason>)`.
Under the opt-in `review.panel risk` (and `review.platform on` under any panel):
| Diff | Pass 1 | After fixes (never more than 2 rounds) |
|---|---|---|
| Risk class: gate class, an R51 class (money, auth, secret, security, data), `eval` (scorer, judge and grader paths) or `policy` (`review.risk_paths`) | The full-depth plan as printed: the batched specialist, Red Team, native adversarial, outside voice and the targeted reader | A fixed blocker re-runs the printed plan on the fixed head as round 2; below-floor fixes get one fix-check |
| Non-risk code | One native adversarial reader (`DISPATCH: adversarial-native single`) at the lane's reasoning, on `review.reader_model` (`lane` by default), carrying the code checklists and the Red Team checklist; it also carries the targeted brief when the diff has a targeted class | A fixed blocker or a gate-class edit gets one fix-check on the touched hunks; below-floor fixes ship as notes |
| Docs and tests | The core read; the outside voice only when the diff publishes numbers | One fix-check after a fixed blocker |
| Docs or tests with no numeric, results or prereg claims, under `review.platform on`, with capy-ai[bot]'s review on the PR at HEAD | No reader: `NEXT: platform` and a planned `SKIP:` for every reader, the core read included | none |
`review.panel all`, the default, keeps today's panel and loop on every class. `risk` is
opt-in because its one-reader shape has not passed its no-loss replay: build C's replay
scored it FIX-METHOD (its readers saw the diff without the repository). `review.panel off` gives
risk class one merged reader too (`DISPATCH: adversarial-native merged`: the native
adversarial brief, the gate-class checklists, the Red Team checklist and the targeted
brief, whose ledger record is the receipt's `review_targeted`). The risk classes come
from paths and changed lines, never from a recorded `review_depth` (FOUN-64). Two
content rules: data and config files (JSON, YAML, TOML, CSV and the like) and analysis
scripts under `docs/`, `results/`, `benchmarks/`, `analysis/` or `notebooks/` are
judged by their path, so a `lease_usd` value or an `os.path.join(sys.argv[1], …)` in a
stats script names no class (HARN-1, harness-evals#231), and binary data such as
`*.sqlite` is data wherever it sits; a docs line that can print a credential (an
`Authorization:` header, a bearer or key-shaped value, an environment key set to a value,
a get or show command for auth config such as `mcp get`) is security (gbrain#8417); code
that reserves, binds, leases or settles spend against a ledger, budget or cap (`bindCheck(ledger, …)`,
`reserveSpend`, `bindCap`) is money (GST-1129, gstack-evals#170). When the matchers miss a class the
lane knows the diff has, `gstack-review-plan --declare-risk <classes>` adds it: a declaration joins the
detected classes, only raises the depth, and prints `RISK_DECLARED: classes=<declared> detected=<found>`.
The outside voice reads its diff from a file the brief names with its sha256; past
2,000,000 bytes it reads a path-scoped slice whose brief lists the files left out.
`--fix-checks <n>` tells the lean loop how many fix-checks ran, so the owner's grant
adds one fix-check after the first and never a second.
**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs**
(markdown no agent or gate reads), **tests** (test files and fixtures), **code**
(everything else, including every `SKILL.md` and template, markdown in a skill
@@ -486,9 +526,14 @@ specialist stats they record). The summary line appends `fix_checks=`,
loop prints the old line.
Beside the counters, `write` records `review_depth` (the depth, runtime lines,
rules hash, base and registry commits, the gate scan bound to the head and the risk classes) and
the queue refuses a receipt below the depth its head needs
([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner
rules hash, base and registry commits, the gate scan bound to the head, the risk classes and
`mode`: lean, panel or platform; with `--declare-risk`, `declared` and `detected` beside `risk`, so a
class the lane added shows apart from what the matchers found) and the queue refuses a receipt below the depth its head needs
([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). After `NEXT: platform`,
`write --platform-review <id>` records `review_depth: platform` with `platform_review`
(capy-ai[bot]'s review id, reviewer and head); write and the queue accept it only under
`review.platform on`, on a docs or tests diff with no numeric, results or prereg claims,
with the review at that exact head, so a new head needs `/ship` again. A stop the owner
accepted after a fix-check edit goes in `review_loop_exception`, never in prose
under `REVIEW_LOOP:`. Step 16 takes the same record:
`gstack-ship-release finish --review-loop-exception <json|@file>` routes the
+6 -6
View File
@@ -1,6 +1,6 @@
# Skill routing and skill contracts
Two opt-in Claude Code hooks that help a model actually run gstack's skills (GST-1088). Both are **off by default**, and with both off nothing changes.
Two Claude Code hooks that help a model actually run gstack's skills (GST-1088). Each has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts`; `gstack-config defaults` prints it. With both off nothing changes.
- **Skill routing** (`skill_routing`, a UserPromptSubmit hook). When a prompt matches an installed skill's `triggers:`, the hook adds one line of context naming that skill, for example "this request fits /investigate. Run /investigate with the Skill tool before you start".
- **Skill contracts** (`skill_contracts`, a Stop hook). A skill can declare the outputs that must exist before it is done. When the model tries to stop without them, the hook blocks once and names what is missing.
@@ -12,9 +12,9 @@ Each switch resolves in this order, and the first one set wins:
1. The `./setup` flag: `--skill-routing` / `--no-skill-routing` and `--skill-contracts` / `--no-skill-contracts` (also `--skill-routing=on|off`). A flag is saved to config, so it survives upgrades.
2. The environment: `GSTACK_SKILL_ROUTING` and `GSTACK_SKILL_CONTRACTS` (`on` or `off`). Not saved.
3. Config: `gstack-config set skill_routing on|off` and `gstack-config set skill_contracts on|off`.
4. The default: off.
4. The default: `SWITCH_DEFAULT` in `lib/skill-route.ts`, the only place it is written. Setup, the hooks, `gstack-config get|list|defaults` and `gstack-skill-route --default <switch>` all read it.
`on` also accepts yes, true and 1; `off` accepts no, false and 0. Setup warns about any other value, uses off for that run, and does not save it.
`on` also accepts yes, true and 1; `off` accepts no, false and 0. Setup warns about any other value, uses the default for that run, and does not save it; the hooks also use the default for such a value. A config file that exists but cannot be read turns both hooks off in setup and in the hooks, whatever the default, because it may hold an `off` you set.
Setup registers a hook only when its switch is on, prints one line the first time it registers it, and removes the registration when the switch is off (or with `--no-team`). The hooks also read the switch every time they run, so this takes effect on the next prompt without re-running setup:
@@ -73,7 +73,7 @@ Every item has an `id` (lowercase letters, digits, `.`, `_`, `-`) and a `kind`,
| kind | Fields | Met when |
|---|---|---|
| `report_labels` | `heading`, `labels[]` | The last assistant message containing the heading has a `Label: value` line for every label, and no value is empty or a placeholder (`[...]`, `<...>`, TBD, TODO, `...`, or a template like `DONE \| BLOCKED`) |
| `check_after_last_edit` | optional `match` (regular expression) | No Edit, Write, MultiEdit or NotebookEdit happened, or a Bash test or repro command ran after the last one. By default a command counts when, with quoted text removed, one of its parts (split on `&&`, `\|\|`, `;`, `\|`) starts with a test runner, build tool or interpreter (bun, npm, pytest, go, cargo, make, node, python, curl, ...) or a script whose name says test, spec, repro, check, verify or lint, directly or behind a wrapper (`bundle exec rspec`, `uv run pytest`, `docker compose run app pytest`, `xvfb-run npm test`, `bash scripts/test.sh`). `git commit --no-verify`, `cat test/a.test.ts` and the shell's `test -f` do not count |
| `check_after_last_edit` | optional `match` (regular expression) | No Edit, Write, MultiEdit or NotebookEdit happened, or a Bash test or repro command ran after the last one. By default a command counts when, with quoted text removed, one of its parts (split on `&&`, `\|\|`, `;`, `\|`) runs a test runner (pytest, jest, vitest, rspec, tsc, ...), a test or check verb (`npm test`, `go test`, `cargo run`, `make check`, `bun run lint`), code through an interpreter (`node repro.js`, `python -c ...`, `bun scripts/repro.ts`), `curl` against a URL, or a script whose name says test, spec, repro, check, verify or lint, directly or behind a wrapper (`bundle exec rspec`, `uv run pytest`, `docker compose run app pytest`, `xvfb-run npm test`, `bash scripts/test.sh`). Installs, builds, cleans and version probes (`bun install`, `npm install`, `go build`, `make clean`, `node --version`), `git commit --no-verify`, `cat test/a.test.ts` and the shell's `test -f` do not count |
| `selected_specialists_dispatched` | optional `marker`, `empty_marker` | The selection line was printed in an assistant message or a Bash result (never a fetched page or file), its names are plain (letters, digits, spaces, `_`, `.`, `-`, at most 40 characters), and each specialist it names has an Agent dispatch that mentions it. The marker is literal text in which a whole-word N stands for a number; the default is `Dispatching N specialists:`. N = 0 (`Dispatching 0 specialists: (small diff)`) or a "specialists skipped" line means zero specialists were selected, which passes. Text in parentheses is not a name (`Dispatching 1 specialists: State Machines (small diff)` selects State Machines) |
| `agent_dispatch` | `match` (regular expression, case-insensitive), optional `marker` | The `marker` line (literal text, like /review's `Dispatching adversarial pass: native`) was printed and an Agent dispatch follows it; otherwise, as the fallback, an Agent dispatch's description or prompt matches `match` |
@@ -109,13 +109,13 @@ The hook checks the most recently loaded skill that declares a contract, and onl
- the session is headless or unattended (`gstack-session-kind`), or
- the turn does not end on a question to the user (a last paragraph ending in "?", an AskUserQuestion call, or `NEEDS_CONTEXT`).
So an interactive session that pauses to ask a question is never blocked. When Claude Code says a Stop hook already continued this turn (`stop_hook_active`), the hook never blocks: after its own block it records the result (`met_after_block` or `miss`). It also allows the stop while Agent dispatches are still running (no result yet, or launched in the background with no completion notice naming that agent), because their outputs may still arrive. Because Claude Code can fire Stop before the turn's last message reaches the transcript file, the hook reads the payload's `last_assistant_message` as the turn's final text.
So an interactive session that pauses to ask a question is never blocked. When Claude Code says a Stop hook already continued this turn (`stop_hook_active`), the hook never blocks: after its own block it records the result (`met_after_block` or `miss`). On a final turn it also allows the stop while Agent dispatches are still running (no result yet, or launched in the background with no completion notice naming that agent), because their outputs may still arrive, and records the run as `deferred`. Once the same agents have been running for 10 minutes since the stop that first saw them, they count as stalled and the contract is checked at the next stop. Because Claude Code can fire Stop before the turn's last message reaches the transcript file, the hook reads the payload's `last_assistant_message` as the turn's final text.
It blocks at most once per skill run. If the model stops again with the contract still unmet, the stop is allowed and the run is recorded as a miss.
### Records
Each Claude Code session has one record, `$GSTACK_STATE_ROOT/skill-contracts/<session id>.json`, written atomically (mode 0600) before any block. Each skill run in it has an outcome: `met`, `met_waived`, `blocked`, `met_after_block` or `miss`. The hook's decisions also go to `$GSTACK_STATE_ROOT/hooks/skill-contract.log`.
Each Claude Code session has one record, `$GSTACK_STATE_ROOT/skill-contracts/<session id>.json`, written atomically (mode 0600) before any block. Each skill run in it has an outcome: `met`, `met_waived`, `blocked`, `met_after_block`, `miss` or `deferred` (stopped while its agents were still running, not yet checked; `report` re-checks it against its transcript). The hook's decisions also go to `$GSTACK_STATE_ROOT/hooks/skill-contract.log`.
## Commands
+5 -1
View File
@@ -910,8 +910,12 @@ gstack's own templates and for any skill folder you own.
add nothing a fresh session already does; edit the line that covers the idea instead of appending; put the fact on the
lowest level that reaches the agent in time (description, SKILL.md, `references/`, the tool's `--help`, the tool's
errors); write the rule and put the story in the commit message.
- **Evidence.** A new skill, step, instruction, check or default needs evidence that it helps on frontier models, or a
dated plan to measure it ([ETHOS.md: 9. Earn Its Place](../ETHOS.md#9-earn-its-place)). Commits carry an `Evidence:`
line, and every gstack skill has a row in the evidence ledger, `docs/inventory/evidence.json`, which
[docs/inventory.md](inventory.md) ranks by load per run.
- **Size.** `gstack-skill-bill` prints bytes and estimated tokens per level, and in a gstack checkout the skill's eval
task file (`EVAL TASKS:`). Commits carry
task file (`EVAL TASKS:`) and its ledger row (`EVIDENCE:`). Commits carry
`Skill-Size: <before>→<after> bytes`; a skill more than 10% over its last `Skill-Diet:` commit is DUE, and a red
context-budget ratchet means diet, not a higher ceiling.
- **Drift.** `test/skill-bin-drift.test.ts` fails when a template or rendered skill calls a gstack bin or flag that
+1 -1
View File
@@ -18,7 +18,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
| `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-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. A label is stored and looked up with every character outside `A-Za-z0-9._-` turned into `_`, so `pnpm lint` and `pnpm_lint` name the same row. `carried` lists the CI-sourced rows at the current tree that grade FRESH, which the ship receipt records as `ci_carried`. /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. `quarantine add|renew|release|check|report` keeps `.gstack/quarantine.json`: a quarantined test still runs, gets up to 3 attempts of the whole command, passes only on a full green attempt and is never counted as a pass (`run --base <ref>` reads the registry from the base branch). `ids --junit <file>` prints a report's test ids. Exit 0 pass, 1 a test failed, 2 usage, 3 no usable report. [Guide](flaky.md). |
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait <pr> [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `<state root>/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
| `gstack-review-bots` | **Review-bot comments** — `fetch [--pr <n>]` reads every root line comment, conversation comment and newest review body from each bot in `pr_wait_gating_bots` and `pr_wait_advisory_bots` (Bot accounts only, all pages, through `gh`) into a private directory with per-bot counts, or prints `REVIEW_BOTS: skip` or `REVIEW_BOTS: error` (never a false zero). `record --dir <dir> --id <comment id> --type fix\|fp\|already-fixed --category <category>` appends one triage outcome to the project and global `review-bot-history.md`, taking the bot and file from the fetched comment. `/review` Step 2.5 and `/ship` Step 10 run it. [Triage reference](../review/greptile-triage.md). |
+10 -7
View File
@@ -2091,8 +2091,8 @@ browse chain 'goto https://example.com | text'
<a id="browse-chromium-path-failed"></a>
### `Chromium at GSTACK_CHROMIUM_PATH=<path> failed to launch: ...`
**Meaning.** Headless browse and make-pdf now use `GSTACK_CHROMIUM_PATH`
(NixOS, macOS 13). That binary could not start.
**Meaning.** Headless browse, headed browse, `browse handoff` and make-pdf use
`GSTACK_CHROMIUM_PATH` (NixOS, macOS 13). That binary could not start.
**Fix.** Point `GSTACK_CHROMIUM_PATH` at a working Chromium, or
`unset GSTACK_CHROMIUM_PATH` to use gstack's bundled one.
@@ -4165,15 +4165,17 @@ refuses to start without a passing check.
**Meaning.** The preregistration says `stack: loaded` (some arm loads gstack or
another skill stack) and its `## No-gstack arm` section is missing, lacks one
of `arms:`, `inputs:`, `prompt:`, `panel:` or `report:`, lists an arm that
of `arms:`, `inputs:`, `prompt:`, `panel:`, `report:`, `masking:`, `install:`
or `audit:` (or says `none` for one of the last three), lists an arm that
is not a `<model>#no-gstack` Arms line, or leaves a model in the comparison
without its own no-gstack arm. Arms match on whole identities, so
`claude-opus-5#no-gstack` does not cover `claude-opus-5-5`.
**Fix.** Add one `<model>#no-gstack` line per model under `## Arms`, list them
on the `arms:` line, and fill the other four keys: the same inputs, a plain
prompt with no skill text, the same panel and judge, and where the result row
goes beside the variants. When no arm loads a skill stack, write
on the `arms:` line, and fill the other keys: the same inputs, a plain
prompt with no skill text, the same panel and judge, where the result row
goes beside the variants, how the run agent is masked from the Capy drives,
the check that no gstack is installed, and the per-arm drive-read audit. When no arm loads a skill stack, write
`stack: none` with a `why:` line instead. The rule is in
[docs/TESTING_INTERNALS.md](TESTING_INTERNALS.md#no-gstack-arm).
@@ -5707,7 +5709,8 @@ closed PRs.
verdict is read from the run itself, never from a lane's report: the
`gate-binding-a<attempt>` artifact evals.yml's report job uploads (verdict,
reconcile exit, flaky cases) and the `report-verdict-a<attempt>` artifact's
`collector-outcomes.json` (the panels that failed the lane and the rule-16
`collector-outcomes.json` (the panels that failed the lane, the report's `✗`
failure lines, which name rule cases that have no panel, and the rule-16
flaky lines). A case the binding calls red already failed twice inside the
run (rule 16 reruns a first failure once), so the red is real. `ready`
refuses with this code when the head's recorded screen run is red, whichever
+7 -6
View File
@@ -140,7 +140,7 @@ Write (or open) the preregistration file. Each section is required and must be f
| Decoys | inputs the scorer must reject or score low; how many, how they were made, what a decoy pass proves |
| Stop rules | the spend cap, a bar missed on the pilot, a scorer below its bar, `budget_exhausted`, the first quota or rate-limit error on a seat-metered arm |
| Held-out exposure | every held-out set the plan touches: prior exposure (which runs, which models), the reservation on it |
| No-gstack arm | required with `stack: loaded`: `arms:`, `inputs:`, `prompt:`, `panel:`, `report:` (below) |
| No-gstack arm | required with `stack: loaded` (below) |
```bash
~/.claude/skills/gstack/bin/gstack-eval-plan prereg --init <path>
@@ -153,11 +153,12 @@ preregistration.
**No-gstack arm.** A head-to-head where any arm loads gstack or another skill stack
(review variants, preamble loadouts, skill diets, a model panel under a stack) also
runs each model with no skill stack at all. Add `<model>#no-gstack` to Arms for every
model, and fill `## No-gstack arm`: `arms:` lists those arms; `inputs:` the same rows
and fixture; `prompt:` the task text only, with no SKILL.md, preamble, hooks or installed
skills, and the same tools; `panel:` the same E2E kind, trials, judge and blinding labels;
`report:` its row beside the variants in every results table and verdict.
runs each model with no skill stack. Add `<model>#no-gstack` to Arms for every model and
fill `## No-gstack arm`: `arms:` those arms; `inputs:` same rows; `prompt:` the task text
only, same tools; `panel:` the same kind, trials, judge and
labels; `report:` its row beside the variants; `masking:` both Capy drives hidden from the
run agent; `install:` no gstack installed; `audit:` drive reads per arm. An arm that read a
gstack skill file is contaminated and gets zero credit.
`PREREG_NO_GSTACK_ARM` (exit 1) names what is missing. The PREREG line shows
`stack=loaded no_gstack_arm=ok` when it is complete, or `stack=none` for an opt-out.
+7 -6
View File
@@ -62,7 +62,7 @@ Write (or open) the preregistration file. Each section is required and must be f
| Decoys | inputs the scorer must reject or score low; how many, how they were made, what a decoy pass proves |
| Stop rules | the spend cap, a bar missed on the pilot, a scorer below its bar, `budget_exhausted`, the first quota or rate-limit error on a seat-metered arm |
| Held-out exposure | every held-out set the plan touches: prior exposure (which runs, which models), the reservation on it |
| No-gstack arm | required with `stack: loaded`: `arms:`, `inputs:`, `prompt:`, `panel:`, `report:` (below) |
| No-gstack arm | required with `stack: loaded` (below) |
```bash
~/.claude/skills/gstack/bin/gstack-eval-plan prereg --init <path>
@@ -75,11 +75,12 @@ preregistration.
**No-gstack arm.** A head-to-head where any arm loads gstack or another skill stack
(review variants, preamble loadouts, skill diets, a model panel under a stack) also
runs each model with no skill stack at all. Add `<model>#no-gstack` to Arms for every
model, and fill `## No-gstack arm`: `arms:` lists those arms; `inputs:` the same rows
and fixture; `prompt:` the task text only, with no SKILL.md, preamble, hooks or installed
skills, and the same tools; `panel:` the same E2E kind, trials, judge and blinding labels;
`report:` its row beside the variants in every results table and verdict.
runs each model with no skill stack. Add `<model>#no-gstack` to Arms for every model and
fill `## No-gstack arm`: `arms:` those arms; `inputs:` same rows; `prompt:` the task text
only, same tools; `panel:` the same kind, trials, judge and
labels; `report:` its row beside the variants; `masking:` both Capy drives hidden from the
run agent; `install:` no gstack installed; `audit:` drive reads per arm. An arm that read a
gstack skill file is contaminated and gets zero credit.
`PREREG_NO_GSTACK_ARM` (exit 1) names what is missing. The PREREG line shows
`stack=loaded no_gstack_arm=ok` when it is complete, or `stack=none` for an opt-out.
+25 -6
View File
@@ -3,13 +3,16 @@
* skill-contract-stop-hook — Stop hook that blocks once when a contract skill
* ends without its required outputs (GST-1088, lane A). Off unless
* skill_contracts is on (env GSTACK_SKILL_CONTRACTS, then config
* skill_contracts; default off).
* skill_contracts, then SWITCH_DEFAULT).
*
* stdin -> switch on? -> transcript_path -> parse (+ last_assistant_message)
* -> the most recently loaded skill that declares a contract (none: allow)
* -> Agent dispatches still running? allow (outputs may still arrive)
* -> final turn? (a STATUS line, a headless or unattended session, or no
* pending user question) -> checkContract (lib/skill-contract.ts)
* pending user question; otherwise allow)
* -> Agent dispatches still running? allow (outputs may still arrive),
* until the same set has been running AGENT_STALL_MS since the stop
* that first saw it; then it is stalled and the contract is checked
* -> checkContract (lib/skill-contract.ts)
* -> unmet and this run never blocked: record "blocked", then block with
* blockMessage(); unmet again later: record "miss" and allow
* -> stop_hook_active (a Stop hook already blocked this turn): record the
@@ -28,7 +31,7 @@
import { runBin } from './spawn-bin';
import { appendHookTrace, installedSkillsDir, readSkillSwitch, readStdinBounded } from '../../../lib/skill-route';
import {
StateError, UnknownShape, blockMessage, bumpCanary, checkContract, lookupContract, nextOutcome, parseTranscript,
AGENT_STALL_MS, StateError, UnknownShape, blockMessage, bumpCanary, checkContract, lookupContract, nextOutcome, parseTranscript,
readRecord, readTranscriptFile, recordPath, runKey, sameSkill, skillLoads, stateErrorLine, withFinalMessage, writeRecord,
type CheckResult, type Contract, type Transcript,
} from '../../../lib/skill-contract';
@@ -104,12 +107,28 @@ async function main(): Promise<void> {
if (!t) return trace({ event: 'no-contract-skill' });
const result = checkContract(transcript, t.skill, t.contract);
if (!result.load) return trace({ event: 'no-contract-skill' });
if (result.pendingAgents.length) return trace({ event: 'agents-running', skill: t.skill, agents: result.pendingAgents.length });
if (!isFinalTurn(result)) return trace({ event: 'not-final-turn', skill: t.skill });
const file = recordPath(sessionId);
const record = readRecord(file) ?? { version: 1 as const, session_id: sessionId, transcript_path: transcriptPath, runs: {} };
const key = runKey(t.skill, result.load);
const prev = record.runs[key];
if (!isFinalTurn(result)) return trace({ event: 'not-final-turn', skill: t.skill });
const pending = [...result.pendingAgents].sort();
if (pending.length) {
const sameSet = pending.join(',') === (prev?.deferred_agents ?? []).join(',') && !!prev?.deferred_at;
if (!sameSet) {
const now = new Date().toISOString();
record.transcript_path = transcriptPath;
record.runs[key] = {
skill: t.skill, load_line: result.load.line, outcome: prev?.outcome ?? 'deferred', missing: prev?.missing ?? [], waived: prev?.waived ?? [],
...(prev?.blocked_at ? { blocked_at: prev.blocked_at } : {}), deferred_agents: pending, deferred_at: now, updated_at: now,
};
writeRecord(file, record);
}
if (!sameSet || !(Date.now() - Date.parse(prev!.deferred_at!) >= AGENT_STALL_MS)) {
return trace({ event: 'agents-running', skill: t.skill, agents: pending.length });
}
trace({ event: 'agents-stalled', skill: t.skill, agents: pending.length });
}
const { outcome, block } = nextOutcome(prev, result);
if (block && stopActive) return trace({ event: 'stop-hook-active', skill: t.skill });
const missing = result.missing.join(',') || '-';
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* skill-route-hook — UserPromptSubmit hook that names the gstack skill a prompt
* fits (GST-1088, lane A). Off unless skill_routing is on (env
* GSTACK_SKILL_ROUTING, then config skill_routing; default off).
* GSTACK_SKILL_ROUTING, then config skill_routing, then SWITCH_DEFAULT).
*
* stdin (<= 256 KiB, 500 ms) -> switch on? -> JSON object with a prompt?
* -> not a /command, not over 8000 chars -> score against installed triggers
+1 -1
View File
@@ -101,7 +101,7 @@ const CERTIFIED_OUTSIDE = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'med
const voiceFile = input.out.replace(/\/+$/, '') + '/voice-models.json';
let voices = { laneModel: null, readers: {} };
let laneFamily = family(input.laneModel);
const modelOf = (r) => r.model || (r.reader in voices.readers ? voices.readers[r.reader] || undefined
const modelOf = (r) => r.model || (r.cross_family ? (input.models || {})['outside-voice'] || (laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined) : undefined) || (r.reader in voices.readers ? voices.readers[r.reader] || undefined
: (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role]
|| (r.role === 'outside-voice' && laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined)
|| (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined));
+1 -1
View File
@@ -54,7 +54,7 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
for (const run of listed) {
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged });
if (v.ok) return v;
refusals.push(`run ${run.id}: ${v.reason}`);
refusals.push(`run ${run.id}: ${v.reason}${v.fix ? ` (the pull request's base moved after the run; fix: ${v.fix})` : ''}`);
}
return no('no_verified_run', `${name}: ${refusals.join('; ')}`);
}
+5 -3
View File
@@ -26,11 +26,12 @@ import { readLedger, release, reserve, settle, SpendCapExceeded, summarize, type
export const PREREG_SECTIONS = ['Bars', 'Arms', 'Comparison', 'Decoys', 'Stop rules', 'Held-out exposure'] as const;
/** `## Comparison`: `stack: loaded` when any arm loads gstack or another skill stack, `stack: none` with a `why:` otherwise. */
export interface ComparisonCheck { stack: 'loaded' | 'none' | null; why: string | null; errors: string[] }
/** `## No-gstack arm`, required when `stack: loaded`: one `<model>#no-gstack` Arms line per model, and the inputs, prompt, panel and report it shares with the variants. */
/** `## No-gstack arm`, required when `stack: loaded`: one `<model>#no-gstack` Arms line per model, the inputs, prompt, panel and report it shares with the variants, and its masking, install and audit lines. */
export interface NoGstackArmCheck { required: boolean; ok: boolean; arms: string[]; problems: string[] }
export interface PreregCheck { ok: boolean; present: string[]; missing: string[]; empty: string[]; comparison: ComparisonCheck; no_gstack: NoGstackArmCheck; diagnostics: PreregDiagnostics | null }
export const NO_GSTACK_LOADOUT = 'no-gstack';
export const NO_GSTACK_KEYS = ['arms', 'inputs', 'prompt', 'panel', 'report'] as const;
/** masking, install and audit keep the arm clean (TESTING_INTERNALS.md#no-gstack-arm): an arm that read a gstack skill file is contaminated. */
export const NO_GSTACK_KEYS = ['arms', 'inputs', 'prompt', 'panel', 'report', 'masking', 'install', 'audit'] as const;
export function preregTemplate(title = 'Eval preregistration'): string {
return `# ${title}
@@ -45,7 +46,7 @@ export function preregTemplate(title = 'Eval preregistration'): string {
<!-- \`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. -->
## No-gstack arm
<!-- Required with \`stack: loaded\`: \`arms:\` one \`<model>#no-gstack\` Arms line per model; \`inputs:\` the same rows and fixture; \`prompt:\` the task only (no SKILL.md, preamble, hooks or installed skills); \`panel:\` the same E2E kind, trials and judge; \`report:\` its row beside the variants. -->
<!-- Required with \`stack: loaded\`: \`arms:\` one \`<model>#no-gstack\` Arms line per model; \`inputs:\` the same rows and fixture; \`prompt:\` the task only (no SKILL.md, preamble, hooks or installed skills); \`panel:\` the same E2E kind, trials and judge; \`report:\` its row beside the variants; \`masking:\` how the run agent is kept from gstack (an empty directory bind-mounted over ~/.capy/drive/user-garry-tan and ~/.capy/drive/org-gstack-gbrain before it starts, or a container that sees no Capy drive); \`install:\` no gstack at ~/.local/share/gstack or ~/.claude/skills; \`audit:\` the per-arm drive-read audit (an arm that read a gstack skill file is contaminated and gets zero credit). -->
## Decoys
<!-- Inputs the scorer must reject or score low; how many, how they were made, what a decoy pass proves. -->
@@ -112,6 +113,7 @@ function checkNoGstackArm(text: string, stack: ComparisonCheck['stack']): NoGsta
if (!body?.length) return { required: true, ok: false, arms: [], problems: ['missing section'] };
const kv = keyValues(body);
const problems = NO_GSTACK_KEYS.filter(k => !kv[k]).map(k => `missing ${k}`);
for (const k of ['masking', 'install', 'audit'] as const) if (/^(none|n\/a|no|-)\.?$/i.test(kv[k] ?? '')) problems.push(`${k}: ${kv[k]} is not a plan`);
const listed = (kv.arms ?? '').split(/[\s,]+/).map(a => a.replace(/^`|`$/g, '')).filter(Boolean);
const ids = armIdentities(text);
for (const a of listed) {
+2 -2
View File
@@ -120,7 +120,7 @@ export interface ShipReceipt {
head_tree?: string; product_patch_id?: string;
/** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */
gstack_version?: string;
review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[] };
review_depth?: { depth: 'platform' | 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[]; mode?: 'lean' | 'panel' | 'platform'; platform_review?: { id: string; head: string; reviewer: string }; declared?: string[]; detected?: string[] };
/** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } };
/** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */
@@ -263,7 +263,7 @@ export const ARTIFACT_SCHEMAS = {
head_tree: str, product_patch_id: str, gstack_version: str,
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
flaky_exception: { type: 'array', items: { type: 'object', required: ['lane', 'runs', 'cases', 'grant', 'by'], properties: { lane: str, runs: strList, cases: strList, grant: str, by: str } } },
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } },
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['platform', 'light', 'standard', 'full'] }, mode: { enum: ['lean', 'panel', 'platform'] }, platform_review: { type: 'object', required: ['id', 'head', 'reviewer'], properties: { id: str, head: str, reviewer: str } }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } }, declared: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } }, detected: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } } } },
review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } },
review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } },
review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } },
+335
View File
@@ -0,0 +1,335 @@
/**
* inventory — every part of gstack, its load per run and its evidence (ETHOS.md: 9. Earn Its Place).
*
* Keys are the skill directories discoverTemplates() finds (the root skill as `gstack`) plus
* SHARED_KEYS. The ledger, docs/inventory/evidence.json, is hand-kept: each row is checked by its
* status's own fields (validateEntry). buildInventory joins the ledger with lib/context-bill.ts
* buildBill (offline token estimates) and the dispatch counts ship and autoplan already document,
* each found by its quoted line so a citation that drifts is named. Read-only except the CLI's write.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { discoverTemplates } from '../scripts/discover-skills';
import { buildBill, contentClass, TOKEN_DIVISOR, TOKEN_DIVISORS } from './context-bill';
import { CAPY_INSTALL_ROOT } from './doctor-components';
export const LEDGER_PATH = 'docs/inventory/evidence.json';
export const DOC_PATH = 'docs/inventory.md';
export const ROOT_KEY = 'gstack';
export interface SharedPart { key: string; label: string; load: string; files: string[] }
/** Shared parts: sized from their files (catalog from the bill's frontmatter). */
export const SHARED_PARTS: SharedPart[] = [
{ key: 'preamble', label: 'shared preamble (sections/preamble.md, identical in every tier 2+ skill)', load: 'every tier 2+ skill run, inside that skill\'s load', files: ['ship/sections/preamble.md'] },
{ key: 'catalog', label: 'skill catalog (every skill\'s frontmatter)', load: 'every session', files: [] },
{ key: 'capy-skill', label: 'capy/skill/gstack/SKILL.md', load: 'every Capy session', files: ['capy/skill/gstack/SKILL.md'] },
{ key: 'capy-factory', label: 'capy/skill/gstack/factory.md', load: 'every Capy session', files: ['capy/skill/gstack/factory.md'] },
{ key: 'ship-review-panel', label: '/ship review panel (review-army and adversarial sections)', load: 'every /ship run, by depth', files: ['ship/sections/review-army.md', 'ship/sections/adversarial.md'] },
{ key: 'ship-docs-audit', label: '/ship docs audit (Step 14.5)', load: 'every /ship run', files: ['ship/sections/documentation.md'] },
{ key: 'autoplan-phases', label: '/autoplan CEO, design, DX and eng phases', load: 'every /autoplan run, by scope', files: ['autoplan/sections/ceo-phase.md', 'autoplan/sections/design-phase.md', 'autoplan/sections/dx-phase.md', 'autoplan/sections/eng-phase.md'] },
];
export const SHARED_KEYS = SHARED_PARTS.map(p => p.key);
export interface Dispatch { skill: 'ship' | 'autoplan'; what: string; count: string; file: string; quote: string }
/** Paid dispatches per run, from the budget tables and docs that already state them (no new instrumentation). */
export const DISPATCHES: Dispatch[] = [
{ skill: 'ship', what: 'Step 9 review, pass 1, light depth, docs or tests only', count: '1 reader (the core checklist)', file: 'docs/review-policy.md', quote: '| **light**, runtime 0 |' },
{ skill: 'ship', what: 'Step 9 review, pass 1, light depth, code', count: '3 readers (core, one batched specialist, native adversarial), plus the outside voice on a diff that publishes numbers', file: 'docs/review-policy.md', quote: '| **light**, runtime above 0 |' },
{ skill: 'ship', what: 'Step 9 review, pass 1, standard depth', count: 'the batched specialist, the Red Team at 200 runtime lines, adversarial and outside voice', file: 'docs/review-policy.md', quote: '| **standard** |' },
{ skill: 'ship', what: 'Step 9 review, pass 1, full depth', count: 'the batched specialist on the whole diff and the Red Team always', file: 'docs/review-policy.md', quote: '| **full** |' },
{ skill: 'ship', what: 'targeted reader on money, gate, auth or secret code', count: '+1 at every depth', file: 'docs/review-policy.md', quote: '`gstack-review-plan` dispatches one' },
{ skill: 'ship', what: 'audits (Steps 7, 8 and 14.5)', count: '3', file: 'lib/ship-budget-calibration.json', quote: 'each audit dispatch (Steps 7, 8, 14.5)' },
{ skill: 'ship', what: 'measured: one /ship child (gstack-evals#31)', count: '10 review dispatches', file: 'lib/ship-budget-calibration.json', quote: 'its 10 review dispatches' },
{ skill: 'ship', what: 'prior cost of one dispatch on the Capy meter', count: '7,430,000 tokens', file: 'lib/ship-budget-calibration.json', quote: '"per_dispatch": 7430000' },
{ skill: 'autoplan', what: 'voices per phase (native and outside)', count: '2', file: 'autoplan/DRIVER.md.tmpl', quote: 'per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to' },
{ skill: 'autoplan', what: 'phases per round: CEO and Eng always, Design with UI scope, DX with developer-facing scope', count: '2 to 4, so 4 to 8 voice dispatches a round', file: 'autoplan/SKILL.md.tmpl', quote: 'Phases MUST run in strict order: CEO → Design (if UI scope) → DX (if developer-facing' },
{ skill: 'autoplan', what: 'CEO spec review loop', count: 'up to 3 launches', file: 'autoplan/sections/ceo-phase.md.tmpl', quote: 'three-launch cap' },
{ skill: 'autoplan', what: 'review rounds per phase (new unattended runs)', count: 'up to 2', file: 'lib/autoplan-budget.ts', quote: 'export const DEFAULT_MAX_ROUNDS = 2;' },
{ skill: 'autoplan', what: 'reserved per voice on Capy or under a spend cap', count: '$5', file: 'lib/autoplan-budget.ts', quote: 'export const DEFAULT_HOST_ESTIMATE_USD = 5;' },
];
export const STATUSES = ['helps', 'no-effect', 'hurts', 'untested', 'safety'] as const;
export type Status = (typeof STATUSES)[number];
export const R48_VERDICTS = ['KEEP', 'FIX-METHOD', 'CUT-CANDIDATE'] as const;
/** R54: the frontier models under test; a row may add at most one named low-cost primary, never Haiku or Sonnet. */
export const FRONTIER_MODELS = ['claude-opus-5-5', 'gpt-6.1-sol'];
/** Why an earlier result does not count as fair evidence yet. */
export const PRIOR_WHY: Record<string, string> = {
stub: 'ran on the Agent SDK stub prompt, not the claude_code preset',
'small-tier': 'Haiku, Sonnet or another non-frontier model only',
unmasked: 'no mask recorded: the no-gstack arm predates the masking rule and was not re-checked',
ceiling: 'control at the ceiling, so no difference could show',
'no-true-no-gstack-arm': 'the none arm still had gstack installed',
'no-exposure': 'the model never used the part',
'not-claude-code': 'ran outside Claude Code (Messages API, Codex or Capy)',
'variant-only': 'variant against variant, no no-gstack arm',
};
const FIELDS = ['status', 'grandfathered', 'plan', 'prior', 'reason', 'owner', 'measure_by', 'sources', 'models', 'prompt', 'scope', 'masked', 'r48', 'seed', 'aliases', 'note'];
export type LedgerCode =
| 'LEDGER_PARSE' | 'LEDGER_KEY_MISSING' | 'LEDGER_KEY_EXTRA' | 'LEDGER_STATUS_INVALID' | 'LEDGER_FIELD_UNKNOWN'
| 'LEDGER_SOURCES_MISSING' | 'LEDGER_MODELS_NOT_FRONTIER' | 'LEDGER_PROMPT_NOT_PRESET' | 'LEDGER_SCOPE_MISSING'
| 'LEDGER_UNMASKED' | 'LEDGER_R48_MISSING' | 'LEDGER_PLAN_MISSING' | 'LEDGER_PLAN_NONE_NEW' | 'LEDGER_PLAN_NOT_CONCRETE'
| 'LEDGER_REASON_MISSING' | 'LEDGER_OWNER_MISSING' | 'LEDGER_MEASURE_BY_MISSING' | 'LEDGER_PRIOR_INVALID' | 'LEDGER_FIELD_INVALID'
| 'INVENTORY_SOURCE_MISSING' | 'INVENTORY_PART_MISSING';
export interface LedgerProblem { key: string; code: LedgerCode; message: string }
export interface LedgerEntry { status: Status; grandfathered?: boolean; plan?: string; prior?: Array<{ ref: string; why: string[]; note?: string }>; reason?: string; owner?: string; measure_by?: string; sources?: string[]; models?: string[]; prompt?: string; scope?: string; masked?: boolean; r48?: { verdict: string; ref: string }; seed?: string; aliases?: string[]; note?: string }
export interface Ledger { parts: Record<string, LedgerEntry>; unmapped?: Array<{ row: string; why: string }>; [k: string]: unknown }
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
const str = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
const validR48 = (v: unknown) => isObj(v) && (R48_VERDICTS as readonly string[]).includes(v.verdict as string) && str(v.ref);
function frontierModels(v: unknown): boolean {
if (!Array.isArray(v) || !v.length || !v.every(str)) return false;
const ids = v.map(m => m.slice(m.lastIndexOf('/') + 1));
const other = ids.filter(m => !FRONTIER_MODELS.includes(m));
return ids.length > other.length && other.length <= 1 && !other.some(m => /haiku|sonnet/i.test(m));
}
/** Problems with one ledger row, by its status's own fields (helps, no-effect, hurts, untested, safety). */
export function validateEntry(key: string, entry: unknown): LedgerProblem[] {
const problems: LedgerProblem[] = [];
const add = (code: LedgerCode, message: string) => problems.push({ key, code, message });
if (!isObj(entry)) { add('LEDGER_STATUS_INVALID', 'the row is not an object'); return problems; }
for (const f of Object.keys(entry)) if (!FIELDS.includes(f)) add('LEDGER_FIELD_UNKNOWN', `unknown field ${f}; fields: ${FIELDS.join(', ')}`);
const prior = entry.prior;
if (prior !== undefined && !(Array.isArray(prior) && prior.every(p => isObj(p) && str(p.ref) && Array.isArray(p.why) && p.why.length > 0 && p.why.every(w => typeof w === 'string' && Object.hasOwn(PRIOR_WHY, w)))))
add('LEDGER_PRIOR_INVALID', `prior needs [{ref, why: [...]}] with why from: ${Object.keys(PRIOR_WHY).join(', ')}`);
if (entry.aliases !== undefined && !(Array.isArray(entry.aliases) && entry.aliases.length > 0 && entry.aliases.every(str))) add('LEDGER_FIELD_INVALID', 'aliases needs a list of names');
const status = entry.status;
if (status === 'helps' || status === 'no-effect' || status === 'hurts') {
if (!Array.isArray(entry.sources) || !entry.sources.length || !entry.sources.every(str)) add('LEDGER_SOURCES_MISSING', `${status} needs sources: one or more run ids or results files`);
if (!frontierModels(entry.models)) add('LEDGER_MODELS_NOT_FRONTIER', `models must include ${FRONTIER_MODELS.join(' or ')} plus at most one named low-cost primary, never Haiku or Sonnet (R54)`);
if (entry.prompt !== 'preset') add('LEDGER_PROMPT_NOT_PRESET', 'prompt must be "preset" (the claude_code system prompt, not the Agent SDK stub)');
if (!str(entry.scope)) add('LEDGER_SCOPE_MISSING', 'scope must say what was measured');
if (entry.masked !== true) add('LEDGER_UNMASKED', 'masked must be true: the no-gstack arm was masked from the Capy drives and gstack installs');
if (status !== 'helps' && !validR48(entry.r48)) add('LEDGER_R48_MISSING', `${status} needs r48: {verdict: ${R48_VERDICTS.join('|')}, ref}`);
} else if (status === 'untested') {
const grandfathered = entry.grandfathered === true;
if (!str(entry.plan)) add('LEDGER_PLAN_MISSING', 'untested needs plan: a reference, or "none" on a grandfathered row');
else if (entry.plan === 'none') { if (!grandfathered) add('LEDGER_PLAN_NONE_NEW', 'plan: none is only for grandfathered rows; a new part names its measurement plan'); }
else if (!grandfathered && !/^\S+\/tasks\.json$|^\S*prereg[^\s/]*\.md$/i.test(entry.plan)) add('LEDGER_PLAN_NOT_CONCRETE', 'a new part\'s plan is the path of a test/fixtures/skill-evals/<name>/tasks.json or of an /eval-plan preregistration (a .md file named for its prereg)');
if (!grandfathered) {
if (!str(entry.reason)) add('LEDGER_REASON_MISSING', 'a new untested part states its reason');
if (!str(entry.owner)) add('LEDGER_OWNER_MISSING', 'a new untested part names its owner');
if (!str(entry.measure_by) || !/^\d{4}-\d{2}-\d{2}$/.test(entry.measure_by) || Number.isNaN(Date.parse(entry.measure_by))) add('LEDGER_MEASURE_BY_MISSING', 'a new untested part needs measure_by: YYYY-MM-DD');
}
} else if (status === 'safety') {
if (!str(entry.reason)) add('LEDGER_REASON_MISSING', 'safety needs reason: what the guard protects');
} else add('LEDGER_STATUS_INVALID', `status must be one of ${STATUSES.join(', ')}`);
if (entry.r48 !== undefined && !validR48(entry.r48) && status !== 'no-effect' && status !== 'hurts') add('LEDGER_R48_MISSING', `r48 needs {verdict: ${R48_VERDICTS.join('|')}, ref}`);
return problems;
}
/** Coverage (no missing, no extra key) then every row's own schema. */
export function validateLedger(ledger: unknown, keys: string[]): LedgerProblem[] {
if (!isObj(ledger) || !isObj(ledger.parts)) return [{ key: '(ledger)', code: 'LEDGER_PARSE', message: `${LEDGER_PATH} needs a parts object` }];
const parts = ledger.parts;
return [
...keys.filter(k => !(k in parts)).map(k => ({ key: k, code: 'LEDGER_KEY_MISSING' as const, message: `no row in ${LEDGER_PATH}` })),
...Object.keys(parts).filter(k => !keys.includes(k)).map(k => ({ key: k, code: 'LEDGER_KEY_EXTRA' as const, message: 'not an inventory key (the key list is in docs/inventory.md)' })),
...keys.filter(k => k in parts).flatMap(k => validateEntry(k, parts[k])),
];
}
export function loadLedger(root: string): { ledger: Ledger | null; problems: LedgerProblem[] } {
try {
return { ledger: JSON.parse(fs.readFileSync(path.join(root, LEDGER_PATH), 'utf8')), problems: [] };
} catch (e) {
return { ledger: null, problems: [{ key: '(ledger)', code: 'LEDGER_PARSE', message: `cannot read ${LEDGER_PATH}: ${(e as Error).message}` }] };
}
}
/** Why root is a gstack install the CLI must never write into, or null: setup's skills/gstack folders and the Capy checkout (GSTACK_CAPY_ROOT, else CAPY_INSTALL_ROOT). */
export function installReason(root: string, env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string | null {
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
if ([path.resolve(root), real(root)].some(p => /[\\/]skills[\\/]gstack$/.test(p))) return 'a skills/gstack install';
const capy = (env.GSTACK_CAPY_ROOT || CAPY_INSTALL_ROOT).replace(/^\$HOME/, home);
return real(root) === real(capy) ? `the Capy install (${capy})` : null;
}
/** The skill keys (discoverTemplates, root skill as gstack, symlinked folders excluded) then SHARED_KEYS. */
export function inventoryKeys(root: string): string[] {
const skills = discoverTemplates(root).map(t => path.dirname(t.tmpl)).map(d => (d === '.' ? ROOT_KEY : d));
return [...skills.sort(), ...SHARED_KEYS];
}
/** Installed skill name to key: gstack-<name> is <name>; gstack-upgrade keeps its name. */
export const keyOf = (name: string) => (name !== 'gstack-upgrade' && name.startsWith('gstack-') ? name.slice('gstack-'.length) : name);
/** The status's evidence in one phrase: sources, plan or reason. */
export function evidenceRef(e: LedgerEntry): string {
if (e.status === 'untested') return `plan: ${e.plan}${e.grandfathered ? ' (grandfathered)' : ` (owner ${e.owner}, measure by ${e.measure_by})`}`;
if (e.status === 'safety') return `reason: ${e.reason}`;
return `sources: ${(e.sources ?? []).join(', ')}${e.r48 ? `; R48 ${e.r48.verdict}` : ''}`;
}
/** gstack-skill-bill's EVIDENCE line for one skill folder in a gstack checkout. */
export function skillEvidence(root: string, dir: string): { key: string; status: Status | 'missing' | 'unreadable'; ref: string; line: string } {
const key = path.relative(root, dir) || ROOT_KEY;
const loaded = loadLedger(root);
if (loaded.problems.length) {
const ref = `${loaded.problems[0]!.message}; fix the file, then run bin/gstack-inventory --check`;
return { key, status: 'unreadable', ref, line: `EVIDENCE: unreadable: ${ref}` };
}
const entry = loaded.ledger?.parts?.[key];
if (!entry) {
const ref = `no ${key} row in ${LEDGER_PATH}; add one (ETHOS.md: 9. Earn Its Place)`;
return { key, status: 'missing', ref, line: `EVIDENCE: missing: ${ref}` };
}
const ref = evidenceRef(entry);
return { key, status: entry.status, ref, line: `EVIDENCE: ${entry.status} ${ref}` };
}
export interface CitedDispatch extends Dispatch { line: number | null }
const tokensOf = (rel: string, bytes: number) => Math.round(bytes / (TOKEN_DIVISORS[contentClass(rel)] ?? TOKEN_DIVISOR));
/** Ledger, dispatch citations and shared-part files; no bill, so --check stays fast. */
export function checkInventory(root: string): { keys: string[]; ledger: Ledger | null; dispatches: CitedDispatch[]; problems: LedgerProblem[] } {
const keys = inventoryKeys(root);
const loaded = loadLedger(root);
const problems = [...loaded.problems, ...(loaded.ledger ? validateLedger(loaded.ledger, keys) : [])];
const dispatches = DISPATCHES.map(d => {
const file = path.join(root, d.file);
const idx = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').findIndex(l => l.includes(d.quote)) : -1;
if (idx < 0) problems.push({ key: `dispatch:${d.skill}`, code: 'INVENTORY_SOURCE_MISSING', message: `${d.file} no longer holds "${d.quote}"; update DISPATCHES in lib/inventory.ts` });
return { ...d, line: idx < 0 ? null : idx + 1 };
});
for (const part of SHARED_PARTS) for (const f of part.files) if (!fs.existsSync(path.join(root, f)))
problems.push({ key: part.key, code: 'INVENTORY_PART_MISSING', message: `${f} is missing; update SHARED_PARTS in lib/inventory.ts` });
return { keys, ledger: loaded.ledger, dispatches, problems };
}
export interface PartRow { key: string; kind: 'skill' | 'shared'; label: string; loads: string; loadTokens: number | null; onDemandTokens: number | null; alwaysOnTokens: number | null; status: Status | 'missing'; ref: string }
export interface Inventory {
commit: string; date: string; tokenSource: string; keys: string[]; parts: PartRow[];
tiers: Array<{ tier: string; skills: string[] }>; dispatches: CitedDispatch[];
prior: Array<{ key: string; ref: string; why: string[]; note?: string }>;
aliases: Array<{ key: string; names: string[] }>;
unmapped: Array<{ row: string; why: string }>; problems: LedgerProblem[];
}
function git(root: string, args: string[]): string | null {
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 10_000 });
return r.status === 0 ? r.stdout.trim() : null;
}
/** The commit the snapshot measured, its date, and whether the tree had other changes. */
function measuredAt(root: string): { commit: string; date: string } {
const top = git(root, ['rev-parse', '--show-toplevel']);
if (!top || path.resolve(top) !== path.resolve(root)) return { commit: 'none', date: 'none' };
const dirty = git(root, ['status', '--porcelain', '--', '.', `:!${DOC_PATH}`]);
return { commit: (git(root, ['rev-parse', '--short=9', 'HEAD']) ?? 'none') + (dirty ? ' (with uncommitted changes)' : ''), date: git(root, ['log', '-1', '--format=%cs']) ?? 'none' };
}
/** Joins the ledger with the context bill: one row per key, ranked by load per run. */
export function buildInventory(root: string): Inventory {
const checked = checkInventory(root);
const bill = buildBill(root);
const resolved = path.resolve(root);
const bySkill = new Map(bill.skills.map(s => [path.relative(resolved, s.dir) || ROOT_KEY, s]));
const parts = checked.ledger?.parts ?? {};
const skillKeys = checked.keys.filter(k => !SHARED_KEYS.includes(k));
const evidence = (key: string) => (parts[key] ? { status: parts[key]!.status, ref: evidenceRef(parts[key]!) } : { status: 'missing' as const, ref: 'no row' });
const rows: PartRow[] = skillKeys.map(key => {
const s = bySkill.get(key);
const hoisted = path.join(root, key, 'sections', 'preamble.md');
const shared = fs.existsSync(hoisted) ? tokensOf(`${key}/sections/preamble.md`, fs.statSync(hoisted).size) : 0;
const onDemand = s && key !== ROOT_KEY ? Math.max(0, Math.round(s.totalMdTokens - s.perInvocationTokens - shared)) : null;
return { key, kind: 'skill', label: key === ROOT_KEY ? 'root skill (/gstack router)' : `/${key}`, loads: 'every use', loadTokens: s ? Math.round(s.perInvocationTokens) : null, onDemandTokens: onDemand, alwaysOnTokens: s ? Math.round(s.frontmatterTokens) : null, ...evidence(key) };
});
const catalog = skillKeys.reduce((n, k) => n + (bySkill.get(k)?.frontmatterTokens ?? 0), 0);
for (const part of SHARED_PARTS) {
const sizes = part.files.map(f => { const file = path.join(root, f); return fs.existsSync(file) ? tokensOf(f, fs.statSync(file).size) : 0; });
const tokens = part.key === 'catalog' ? Math.round(catalog) : sizes.reduce((a, b) => a + b, 0);
rows.push({ key: part.key, kind: 'shared', label: part.label, loads: part.load, loadTokens: tokens, onDemandTokens: null, alwaysOnTokens: part.key === 'catalog' ? tokens : null, ...evidence(part.key) });
}
rows.sort((a, b) => (b.loadTokens ?? -1) - (a.loadTokens ?? -1) || a.key.localeCompare(b.key));
const tiers = new Map<string, string[]>();
for (const key of skillKeys) {
const tmpl = path.join(root, key === ROOT_KEY ? '' : key, 'SKILL.md.tmpl');
const tier = /^preamble-tier:\s*(\d+)/m.exec(fs.readFileSync(tmpl, 'utf8'))?.[1] ?? 'none';
tiers.set(tier, [...(tiers.get(tier) ?? []), key]);
}
return {
...measuredAt(root),
tokenSource: bill.tokenSource,
keys: checked.keys,
parts: rows,
tiers: [...tiers].sort(([a], [b]) => a.localeCompare(b)).map(([tier, skills]) => ({ tier, skills })),
dispatches: checked.dispatches,
prior: checked.keys.flatMap(key => (parts[key]?.prior ?? []).map(p => ({ key, ...p }))),
aliases: checked.keys.filter(k => parts[k]?.aliases?.length).map(k => ({ key: k, names: parts[k]!.aliases! })),
unmapped: checked.ledger?.unmapped ?? [],
problems: checked.problems,
};
}
const n = (v: number | null) => (v === null ? 'n/a' : v.toLocaleString('en-US'));
const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
function renderRanking(inv: Inventory): string[] {
const out = ['## Parts by load per run', '', 'Load per run: a skill\'s SKILL.md plus the files it must read on every use (`perInvocationTokens` in lib/context-bill.ts); a shared part\'s files. Tier 2+ skills also read the shared preamble once per session (its own row). On demand: the skill\'s other sections/ and references/ files, read when a step asks for them; files a shared row also counts, such as /ship\'s review sections, appear in both. Always-on: frontmatter every session\'s skill scanner reads.', '',
'| Rank | Part | Kind | Load per run (tokens) | On demand (tokens) | Always-on (tokens) | Loads | Status | Source, plan or reason |', '|---|---|---|---|---|---|---|---|---|'];
inv.parts.forEach((p, i) => out.push(`| ${i + 1} | ${p.key} | ${p.kind} | ${n(p.loadTokens)} | ${n(p.onDemandTokens)} | ${n(p.alwaysOnTokens)} | ${cell(p.loads)} | ${p.status} | ${cell(p.ref)} |`));
return out;
}
function renderMeasureNext(inv: Inventory): string[] {
const out = ['## Measure next', '', 'Untested parts, largest load per run first. A fair test runs the claude_code preset, frontier models (Opus 5.5 and GPT-6.1 Sol, plus at most one named low-cost primary) and a masked no-gstack arm ([TESTING_INTERNALS.md](TESTING_INTERNALS.md#no-gstack-arm)). Safety rows are not on this list.', '',
'| Order | Part | Load per run (tokens) | Plan |', '|---|---|---|---|'];
inv.parts.filter(p => p.status === 'untested').forEach((p, i) => out.push(`| ${i + 1} | ${p.key} | ${n(p.loadTokens)} | ${cell(p.ref.replace(/^plan: /, ''))} |`));
return out;
}
function renderShared(inv: Inventory): string[] {
const out = ['## Shared parts and preamble tiers', '', '| Tier | Skills | What loads |', '|---|---|---|'];
const what: Record<string, string> = { '1': 'a short preamble inline in SKILL.md', none: 'no preamble' };
for (const t of inv.tiers) out.push(`| ${t.tier} | ${t.skills.join(', ')} | ${what[t.tier] ?? 'the shared sections/preamble.md (row preamble), once per session'} |`);
out.push('', ...SHARED_PARTS.map(p => `- **${p.key}**: ${p.label}${p.files.length ? ` (${p.files.join(', ')})` : ''}; loads ${p.load}.`));
return out;
}
function renderDispatches(inv: Inventory): string[] {
const out = ['## Paid dispatches per run', '', 'Each count is quoted from the file and line named; `bin/gstack-inventory --check` fails when a quoted line moves away.', '', '| Skill | What | Count | Source |', '|---|---|---|---|'];
for (const d of inv.dispatches) out.push(`| ${d.skill} | ${cell(d.what)} | ${cell(d.count)} | ${d.line ? `${d.file}:${d.line}` : `${d.file}: source not found`} |`);
return out;
}
function renderPrior(inv: Inventory): string[] {
const out = ['## Earlier results that do not count yet', '', ...Object.entries(PRIOR_WHY).map(([k, v]) => `- \`${k}\`: ${v}.`), '', '| Part | Result | Why it does not count | Note |', '|---|---|---|---|'];
for (const p of inv.prior) out.push(`| ${p.key} | ${cell(p.ref)} | ${p.why.join(', ')} | ${cell(p.note ?? '')} |`);
return out;
}
function renderKeys(inv: Inventory): string[] {
const skills = inv.keys.filter(k => !SHARED_KEYS.includes(k));
return ['## Key list and aliases', '', `${inv.keys.length} keys: ${skills.length} skills (the folders \`discoverTemplates()\` finds, the root skill as \`gstack\`) and ${SHARED_KEYS.length} shared parts.`, '',
`- Skills: ${skills.join(', ')}.`, `- Shared: ${SHARED_KEYS.join(', ')}.`,
'- Installed names: `gstack-<name>` is key `<name>` (`gstack-ship` is `ship`); `gstack-upgrade` keeps its name; the root skill installs as `gstack`.',
...(inv.aliases.length ? [`- Other names (the ledger's \`aliases\`): ${inv.aliases.map(a => `${a.names.join(', ')} → ${a.key}`).join('; ')}.`] : [])];
}
function renderNotHere(inv: Inventory): string[] {
return ['## Not in this inventory', '',
'- Out of scope: openclaw/skills/* and browser-skills/* (not gstack skills an agent loads from this checkout), and connect-chrome (a symlink to open-gstack-browser, counted once).',
...inv.unmapped.map(u => `- Evidence-table row "${u.row}": ${u.why}.`)];
}
export function renderInventory(inv: Inventory): string {
const count = (s: string) => inv.parts.filter(p => p.status === s).length;
const planned = inv.parts.filter(p => p.status === 'untested' && !p.ref.startsWith('plan: none')).length;
return [
'# gstack inventory: every part, its load per run and its evidence', '',
`Generated by \`bin/gstack-inventory\` from [evidence.json](inventory/evidence.json), the hand-kept ledger, and lib/context-bill.ts. Measured at commit ${inv.commit} (${inv.date}). This is a dated snapshot: rerun \`bin/gstack-inventory\` to refresh it; \`bin/gstack-inventory --check\` validates the ledger. The rule: [ETHOS.md: 9. Earn Its Place](../ETHOS.md#9-earn-its-place).`, '',
`Token counts are offline estimates (${inv.tokenSource}, about ±40%), not billed tokens.`, '',
'| Status | Parts |', '|---|---|',
...(['helps', 'no-effect', 'hurts'] as const).map(s => `| ${s} | ${count(s)} |`),
`| untested, with a plan | ${planned} |`, `| untested, plan none | ${count('untested') - planned} |`, `| safety | ${count('safety')} |`, '',
...renderRanking(inv), '', ...renderMeasureNext(inv), '', ...renderShared(inv), '', ...renderDispatches(inv), '',
...renderPrior(inv), '', ...renderKeys(inv), '', ...renderNotHere(inv), '',
].join('\n');
}
+8 -3
View File
@@ -38,9 +38,14 @@ 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);
export const PREGATE_OUT_REL = '.gstack/tmp/pregate.json';
/** Where `--stage guards` (the pre-push housekeeping guards) writes by default, so it never replaces the full run's file. */
export const PREGATE_GUARDS_OUT_REL = '.gstack/tmp/pregate-guards.json';
/** The checks `--stage guards` runs: generated-file freshness and the repo's guards, each only for the files the diff touched. */
export const PREPUSH_CHECK_IDS = ['regen', 'guards'] as const;
export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches', 'integrator'] as const;
export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string }
/** `guards` selects PREPUSH_CHECK_IDS (preflight checks): the pre-push run, docs/pregate.md#prepush. */
export interface RunOptions { stage: Stage | 'guards' | 'all'; only?: string[]; run?: string }
/** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */
export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult {
@@ -83,7 +88,7 @@ const git = (cwd: string, args: string[]): string | null => {
};
export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<PregateFile> {
const selected = CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id)));
const selected = o.stage === 'guards' ? CHECKS.filter(c => (PREPUSH_CHECK_IDS as readonly string[]).includes(c.id)) : CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id)));
if (selected.some(c => c.id === 'lanes')) { const line = autoCarry(ctx); if (line) ctx.progress(line); }
const checks: CheckResult[] = [];
const clocks: Record<Stage, number> = { preflight: 0, tests: 0 };
@@ -99,7 +104,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
const file: PregateFile = {
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run, scratch: ctx.scratch,
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' || o.stage === 'guards' ? undefined : clocks.tests,
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
requires_remote: remote,
};
+2 -2
View File
@@ -22,8 +22,8 @@ export const EVAL_RESULT_CODES = {
// Garry, 2026-10-31: "all of our head to head tests have to test no gstack from here".
PREREG_NO_GSTACK_ARM: {
anchor: 'prereg-no-gstack-arm',
summary: 'the Comparison says stack: loaded and the No-gstack arm section is missing, lacks a key, or does not give every model its own <model>#no-gstack Arms line',
fix: 'add `## No-gstack arm` with arms:, inputs:, prompt:, panel: and report:, and list each <model>#no-gstack arm under Arms; when no arm loads a skill stack, say stack: none with a why: line',
summary: 'the Comparison says stack: loaded and the No-gstack arm section is missing, lacks a key (masking:, install: and audit: included), says none for a masking key, or does not give every model its own <model>#no-gstack Arms line',
fix: 'add `## No-gstack arm` with arms:, inputs:, prompt:, panel:, report:, masking:, install: and audit:, and list each <model>#no-gstack arm under Arms; when no arm loads a skill stack, say stack: none with a why: line',
},
EVAL_REPORT_NO_GSTACK_ROW: {
anchor: 'eval-report-no-gstack-row',
+32 -3
View File
@@ -37,6 +37,26 @@ export function promptHash(text: string): string {
export class BriefError extends Error {}
/**
* The outside voice's diff within a byte cap (FOUN-32 §3): whole per-file sections in diff order while they fit, so a reader past
* the cap still starts on a path-scoped slice and the brief names what it left out, instead of failing to start on a 3-4 MB brief.
*/
export function sliceDiff(text: string, cap: number): { text: string; sliced: boolean; files: number; kept: number; bytes: number; total: number; left: string[] } {
const total = Buffer.byteLength(text);
const starts = [...text.matchAll(/^diff --git a\/(\S+)/gm)];
if (total <= cap || !starts.length) return { text, sliced: false, files: starts.length, kept: starts.length, bytes: total, total, left: [] };
let out = '', bytes = 0;
const left: string[] = [];
starts.forEach((m, k) => {
const section = text.slice(m.index!, k + 1 < starts.length ? starts[k + 1]!.index! : text.length);
const n = Buffer.byteLength(section);
if (bytes + n <= cap) { out += section; bytes += n; } else left.push(m[1]!);
});
return { text: out, sliced: true, files: starts.length, kept: starts.length - left.length, bytes, total, left };
}
/** The brief's line for a sliced diff: what was read and what was left out (at most 50 paths named). */
export const sliceNote = (s: ReturnType<typeof sliceDiff>, cap: number) => `This is a path-scoped slice: ${s.kept} of ${s.files} files, ${s.bytes} of ${s.total} bytes (the cap is ${cap} bytes). Not in the slice, so not reviewed here: ${s.left.slice(0, 50).join(', ')}${s.left.length > 50 ? ` and ${s.left.length - 50} more` : ''}.`;
/** The text strictly between start and end, end searched after start; both anchors are required. */
function between(text: string, start: string, end: string, what: string): string {
const i = text.indexOf(start);
@@ -134,12 +154,21 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
['{red-team checklist path}', need(slots, 'checklist')], ['<diff-base>', diffBase]], role);
return fixtures(`${t.framing}\n\n${t.fixtures}\n\n${body}`);
}
case 'adversarial-native':
return fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role));
case 'adversarial-native': {
const native = fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role));
const lists = (slots.checklists ?? '').split(',').map(s => s.trim()).filter(Boolean);
const merged = lists.length ? `${native}\n\nIn the same read, apply each checklist at ${lists.join(', ')} to the diff, and tag each finding's "specialist" with the name of the checklist it came from (adversarial for your own).` : native;
if (!slots.classes) return merged;
const classes = parseClassList(slots.classes);
if (!classes?.length) throw new BriefError(`slot classes must list money, gate, auth or secret, got ${JSON.stringify(slots.classes)}`);
try { return `${targetedBrief(need(slots, 'brief'), classes)}\n\n${merged}\n\nApply the targeted brief above (${need(slots, 'checklist')}: ${classes.join(', ')}) in the same read; your reply is this diff's targeted pass.`; } catch (e) { throw e instanceof BriefError ? e : new BriefError((e as Error).message); }
}
case 'outside-voice': {
const filled = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role);
const body = slots.policy_exclude ? filled.split('git diff "$DIFF_BASE".').join(`git diff "$DIFF_BASE" -- . ${slots.policy_exclude}${slots.keep ? ` and git diff "$DIFF_BASE" -- ${slots.keep}` : ''}.`) : filled;
return [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
const head = [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`];
if (slots.diff_file) return [...head, `The supplied branch diff is the file ${slots.diff_file} (${need(slots, 'diff_bytes')} bytes, sha256 ${need(slots, 'diff_sha256')}). Read it from disk; it is ${slots.diff_slice ? 'the part of the diff under review' : 'the whole diff under review'}.`, ...(slots.diff_slice ? [slots.diff_slice] : [])].join('\n');
return [...head, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
}
case 'targeted': {
const classes = parseClassList(need(slots, 'classes'));
+119 -18
View File
@@ -13,14 +13,14 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ResultCodeName } from './result-codes';
import { matchesAny } from './ship-policy';
import { decodeCharRefs, matchRisk, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk';
import { decodeCharRefs, DOC_SECRET_LINE, matchRisk, RISK_CLASSES, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk';
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
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' | 'voices' | 'done' | 'stop';
export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'platform' | '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];
@@ -63,6 +63,8 @@ export const isGatePath = (p: string) => GATE_PATH.some(r => r.test(p));
export const isAgentProse = (p: string) => AGENT_PROSE.some(r => r.test(p));
export const isDataConfig = (p: string) => DATA_CONFIG.some(r => r.test(p));
/** Binary data, media and run logs (HARN-1: committed sqlite ledgers, trial logs): data by path wherever they sit, never code, consumed or not. */
export const BINARY_DATA_PATH = /\.(sqlite3?|db|duckdb|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|pdf|mp3|mp4|mov|wav|webm|log|out)$|(^|\/)logs\/[^/.]+$/i;
/** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing); executable SVG stays code. */
export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i;
/** Data-shaped files tools read by convention, with no code naming them: always code. */
@@ -164,7 +166,7 @@ export function svgDrawing(text: string): boolean {
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS],
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_TAG_NAME: [SVG_TAG_NAME], SVG_ATTR: [SVG_ATTR], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
patterns: Object.entries({ BINARY_DATA_PATH: [BINARY_DATA_PATH], GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_TAG_NAME: [SVG_TAG_NAME], SVG_ATTR: [SVG_ATTR], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
/**
@@ -240,13 +242,19 @@ export interface ClassInput {
removed?: Record<string, readonly string[]>;
/** Paths the base policy's review_exclude_paths leave out of every reader's diff (lib/review-exclude.ts): classified as docs. */
excluded?: ReadonlySet<string>;
/** The base policy's review.risk_paths: a code path they match is risk class `policy`; data and binary files under them never are. */
riskPaths?: readonly string[];
/** Risk classes the lane declares for the diff (GST-1129, --declare-risk): they join the detected ones and can only raise the depth. */
declared?: readonly RiskClass[];
}
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[] }
/** risk is detected plus declared; `detected` is what the matchers found, `declared` what the lane added. */
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[]; detected: RiskClass[]; declared: RiskClass[] }
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root)));
const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root));
const binaryData = (i: ClassInput, p: string) => BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(i, p) ? 'docs' : classifyPath(p, i.root));
/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
export function classifyDiff(i: ClassInput): ClassResult {
@@ -277,17 +285,23 @@ export function classifyDiff(i: ClassInput): ClassResult {
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
if (i.registry?.error) errors.push(i.registry.error);
}
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true' }) : new Map<RiskClass, string>();
const risk = [...riskHits.keys()];
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map<RiskClass, string>();
const docSecret = i.paths.find(p => pathClass(i, p) === 'docs' && DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l))));
if (docSecret && !riskHits.has('security')) riskHits.set('security', `docs that can print a credential: ${docSecret}`);
const byRank = (a: RiskClass, b: RiskClass) => RISK_CLASSES.indexOf(a) - RISK_CLASSES.indexOf(b);
const detected = [...riskHits.keys()].sort(byRank);
const declared = [...new Set(i.declared ?? [])].sort(byRank);
for (const d of declared) if (!riskHits.has(d)) riskHits.set(d, `declared: ${d}`);
const risk = [...riskHits.keys()].sort(byRank);
reasons.push(...[...riskHits.values()].map(r => `risk: ${r}`));
if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null, risk };
if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null, risk, detected, declared };
let runtimeLines = 0;
for (const e of i.perPath) {
if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue;
if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" });
else runtimeLines += e.lines;
}
return { class: cls, reasons, errors, runtimeLines, risk };
return { class: cls, reasons, errors, runtimeLines, risk, detected, declared };
}
/** Risk, not size (R51): any classifier error, gate class or risk class is full; everything else is light. A floor only raises; a fixed blocker or a CRITICAL raises light to standard for the next step (planReview). */
@@ -296,14 +310,14 @@ export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: read
return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d;
}
export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string; risk: RiskClass[] }
export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string; risk: RiskClass[]; detected: RiskClass[]; declared: RiskClass[] }
function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult {
const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk });
const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), ...c.risk.map(r => `risk:${r}`), `runtime=${runtimeLines}`];
if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`);
const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))];
return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES, risk: c.risk };
return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES, risk: c.risk, detected: c.detected, declared: c.declared };
}
/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */
@@ -362,6 +376,10 @@ export interface History {
adversarialRan?: boolean;
/** The approval id of the owner's recorded grant of one scoped fix-check after round 2 (review_loop_exception). */
granted?: string;
/** R65's lean table (planReview sets it from the base policy's review.panel): one reviewer, then at most one fix-check. */
lean?: boolean;
/** Fix-checks already run in this invocation (lean: the grant adds one only after the first). */
fixChecks?: number;
}
export interface NextStep { step: Step; reason: string }
@@ -378,13 +396,31 @@ export interface NextStep { step: Step; reason: string }
* 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);
const n = h.lean ? leanStep(h, depth) : 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` };
if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's ${h.lean ? 'one reader' : 'voices (adversarial, outside, targeted)'} run${h.lean ? 's' : ''} before the owner's exit, then ask again with --adversarial-ran (${n.reason})` };
if (!h.lean && 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;
}
/**
* R65's lean table (GSTA-258): pass 1 is the one reviewer; any fix on a risk-class diff, and a fixed blocker or a gate-class
* edit on any diff, earns one scoped fix-check (round 2, the cap); below-floor fixes on a light diff ship as notes. After the
* fix-check a blocker or gate-class edit stops for the owner, whose recorded grant adds one more fix-check, never two.
*/
function leanStep(h: History, depth?: Depth): NextStep {
if (h.last === 'none') return { step: 'full', reason: 'pass 1: one reviewer reads the whole diff (R65)' };
if (h.last === 'fix-check') {
if (!h.blockingOpen && !h.fixedBlocking && !h.touchedGate) return { step: 'done', reason: h.fixedBelow ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' };
if (h.granted && !h.blockingOpen && h.fixChecks === 1) 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 second` };
return { step: 'stop', reason: `the fix-check ${h.blockingOpen || h.fixedBlocking ? 'found a blocking finding' : 'edited a gate-class path'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` };
}
if (h.blockingOpen) return { step: 'stop', reason: 'a blocking finding is still open in fix scope: name it for the owner' };
if (!h.fixedBlocking && !h.fixedBelow && !h.touchedGate) return { step: 'done', reason: 'the step applied no edits' };
if (depth === 'light' && !h.fixedBlocking && !h.touchedGate) return { step: 'done', reason: 'light depth: the below-floor fixes ship as notes in the PR body and receipt without a fix-check (R65)' };
return { step: 'fix-check', reason: `${h.fixedBlocking ? 'a blocking finding was fixed' : h.touchedGate ? 'a fix touched a gate-class path' : 'below-floor fixes on a risk-class diff'}: one scoped fix-check reads the touched hunks (round 2, the cap)` };
}
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);
@@ -432,14 +468,20 @@ export interface PlanInput extends ClassInput {
targeted?: { classes: readonly string[]; ran?: boolean };
/** The base policy's exclusion: one `:(top,exclude,glob)` pathspec per applied pattern, added to every reader, and the kept files under them as `:(top,literal)` pathspecs. */
exclusion?: { specs: readonly string[]; keep: readonly string[] };
/** The base policy's review key and the platform evidence at HEAD; absent plans today's panel (review.panel all). */
shape?: ReviewShape;
}
/** How much review a repo runs (lib/ship-policy-review.ts) plus HEAD and the platform review found at it (lib/review-platform.ts). */
export interface ReviewShape { panel: 'off' | 'risk' | 'all'; readerModel: string; platform: 'off' | 'on'; head: string; platformReview: { id: string; head: string; reviewer: string } | null }
/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */
export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: string[] }
export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: string[]; model?: string; classes?: string[] }
/** lean: R65's one reviewer; panel: today's panel (review.panel); platform: the platform review at HEAD stands in for every reader. */
export type ReviewMode = 'lean' | 'panel' | 'platform';
export interface ReviewPlan extends DepthResult {
/** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */
stepDepth: Depth;
depthReasons: string[];
next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[];
next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[]; mode: ReviewMode;
}
const scopeWants: Partial<Record<Specialist, (s: Record<string, string>) => boolean>> = {
@@ -517,7 +559,11 @@ 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, gate: cls === 'gate', adversarialRan: !!i.adversarialRan }, depth.depth);
const sh = i.shape;
const platformNow = !!sh && sh.platform === 'on' && sh.platformReview?.head === sh.head && i.history.fullPasses === 0 && !riskClass(c, depth) && platformFits(c.class, i.paths, depth.runtimeLines, i.numbers);
const panel = !platformNow && (!sh || sh.panel === 'all' || (sh.panel === 'risk' && riskClass(c, depth)));
const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan, lean: !panel }, depth.depth);
if (!panel) return leanPlan(i, c, depth, next);
const h = i.history;
const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
const stepDepth: Depth = raise ? 'standard' : depth.depth;
@@ -587,7 +633,62 @@ export function planReview(i: PlanInput): ReviewPlan {
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
}
return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped };
return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped, mode: 'panel' };
}
/**
* R65's lean dispatch (docs/review-policy.md#review-depth): pass 1 runs one native adversarial reader at the lane's reasoning, on
* review.reader_model (the lane's model by default: FOUN-32, ggstack#52). A risk-class diff under review.panel off gets the merged
* reader: the gate-class checklists, the Red Team checklist and the targeted brief for its classes; a non-risk code diff gets the
* code-class checklists and the Red Team. Docs and tests keep the core read. A docs or tests diff with no numeric, results or prereg
* claims, under review.platform on, with the platform review at HEAD, runs no reader at all (mode platform).
*/
/** Risk class read from the paths and lines (gate class, an R51 or review.risk_paths class, a classifier error), or a floor above light (FOUN-64 item 7: never the recorded review_depth). */
const riskClass = (c: ClassResult, depth: DepthResult) => c.class === 'gate' || c.risk.length > 0 || c.errors.length > 0 || depth.depth !== 'light';
/** Whether a light diff may take the platform depth (FOUN-64 item 2): docs or tests only, no numeric claims, no results, prereg, eval or checker path. The receipt and the queue ask the same. */
export const platformFits = (cls: DiffClass, paths: readonly string[], runtimeLines: number, numbers: NumbersResult) => (cls === 'docs' || cls === 'tests') && runtimeLines === 0 && !numbers.publishes && !paths.some(p => CLAIM_PATH.test(p));
/** Paths that carry results or preregistration claims: never the platform depth (FOUN-64 item 2). */
const CLAIM_PATH = /(^|\/|[-_.])(results?|prereg|preregistration|benchmarks?|evals?|scorers?|checkers?)([-_./]|$)/i;
function leanPlan(i: PlanInput, c: ClassResult, depth: DepthResult, next: NextStep): ReviewPlan {
const sh = i.shape!;
const risky = riskClass(c, depth);
const skipped: string[] = [];
const dispatches: Dispatch[] = [];
const x = i.exclusion;
const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial<Dispatch> = {}) =>
dispatches.push({ role, name, effort, exclude: x ? [...exclude, ...x.specs] : exclude, ...extra, ...(x?.keep.length ? { keep: [...x.keep] } : {}) });
const plan = (n: NextStep, mode: ReviewMode): ReviewPlan => ({ ...depth, stepDepth: depth.depth, depthReasons: depth.reasons, reasons: c.reasons, next: n, numbers: i.numbers, dispatches, skipped, mode });
const pr = sh.platformReview;
const platformFit = !risky && platformFits(c.class, i.paths, depth.runtimeLines, i.numbers);
if (next.step === 'full' && platformFit && sh.platform === 'on' && pr && pr.head === sh.head) {
const why = `planned skip: platform review ${pr.id} by ${pr.reviewer} at ${pr.head.slice(0, 12)}`;
for (const r of ['core checklist', 'specialists', 'red-team', 'adversarial-native', 'outside-voice', 'targeted']) skipped.push(`${r} (${why})`);
return plan({ step: 'platform', reason: `review.platform on: the platform review ${pr.id} read HEAD ${pr.head.slice(0, 12)}, so /ship runs no reader (Step 11.5 binds the planned skip)` }, 'platform');
}
if (sh.platform === 'on' && next.step === 'full' && !risky) skipped.push(`platform depth (${depth.runtimeLines > 0 ? 'runtime code' : i.numbers.publishes ? 'the diff publishes numbers' : !platformFit ? 'a results, prereg, eval or checker path' : pr ? `the platform review read ${pr.head.slice(0, 12)}, not HEAD` : 'no platform review at HEAD'}: the light read runs)`);
const classes = i.targeted?.classes.length && !i.targeted.ran ? { classes: [...i.targeted.classes] } : {};
const docsAndOutputs = i.paths.filter(p => !i.excluded?.has(p) && ((pathClass(i, p) === 'docs' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p))).map(excludeSpec);
const reader = () => risky
? add('adversarial-native', 'merged', 'full', [], { checklists: [...batchChecklists('gate', i, skipped), 'red-team'], model: sh.readerModel, ...classes })
: add('adversarial-native', 'single', 'full', docsAndOutputs, { checklists: [...batchChecklists('code', i, skipped), 'red-team'], model: sh.readerModel, ...classes });
if (next.step === 'full' && (risky || depth.runtimeLines > 0)) {
reader();
skipped.push(`specialists, red-team and outside-voice as their own readers (R65: the one ${risky ? 'risk-class' : 'light'} reader carries them)`);
} else if (next.step === 'full') {
for (const n of SPECIALISTS.filter(forcedBy(i))) add('specialist', n, i.tiers === false ? 'host' : ROLE_EFFORT.specialist);
if (i.numbers.publishes) add('outside-voice', 'outside-voice', 'host');
else skipped.push(`outside-voice (${depth.class} class, no numeric claims)`);
} else if (next.step === 'voices') reader();
else if (next.step === 'fix-check') {
add('fix-check', 'fix-check', 'full');
if (i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint) add('outside-voice', 'outside-voice (numbers changed)', 'host');
}
if (next.step === 'fix-check' && i.targeted?.classes.length) {
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
}
return plan(next, 'lean');
}
export function reviewPlanLine(p: ReviewPlan, pass: number): string {
+5 -1
View File
@@ -14,6 +14,7 @@
* 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.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { checkReviewedTree, isReviewedTreeLine, promptHash, type TreeCheck } from './review-brief';
@@ -448,7 +449,10 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
const collected = outputs.some(({ e }) => reviewed(e, r));
const unavailable = !collected && outputs.some(({ e }) => unavailableOutput(e));
const status = !sent.length ? 'NOT_SENT' : mismatched ? 'MISMATCH' : collected ? 'MATCH' : unavailable ? 'UNAVAILABLE' : 'NOT_COLLECTED';
const tampered = fileNow && fileNow !== r.sha256 ? ' file_changed_since_render' : '';
const payload = r.payload as { file?: unknown; sha256?: unknown } | undefined;
let payloadNow: string | null = null;
if (!overwritten && payload && typeof payload.file === 'string') { try { payloadNow = createHash('sha256').update(fs.readFileSync(payload.file)).digest('hex'); } catch { payloadNow = ''; } }
const tampered = (fileNow && fileNow !== r.sha256 ? ' file_changed_since_render' : '') + (payloadNow !== null && payloadNow !== payload!.sha256 ? ' payload_changed_since_render' : '');
rows.push({ kind: 'BRIEF', reader: p.label, status: tampered ? 'MISMATCH' : status, detail: `rendered=${short(r.sha256)} sent=${sent.map(({ e }) => short(e.sha256)).join(',') || '-'}${epVoided ? ` voided=${epVoided}` : ''}${outputs.length && !collected && !unavailable ? ' not_reviewed' : ''}${tampered}` });
}
for (const [i, o] of events.entries()) {
+17 -5
View File
@@ -11,10 +11,11 @@
* persisted before its subagent is created and its path right after, so a resumed program
* reconciles by deterministic title and never doubles a reader.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { BriefError, briefFile, checkoutRoot, checkoutTree, promptHash, renderBrief, withWorkspace, type BriefRole, type SectionTexts, type Slots } from './review-brief';
import { disclosureDigest } from './review-exclude';
import { BriefError, briefFile, checkoutRoot, checkoutTree, promptHash, renderBrief, sliceDiff, sliceNote, withWorkspace, type BriefRole, type SectionTexts, type Slots } from './review-brief';
import { REVIEW_DIFF_MAX_BYTES, disclosureDigest } from './review-exclude';
import { appendEvent, readerKey } from './review-ledger';
import { diffCommand, type Dispatch } from './review-depth';
import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedRead } from './review-targeted';
@@ -31,6 +32,8 @@ export interface PassReader {
classes?: string[]; diff_base?: string;
/** An audit reader: its prompt is a file the parent wrote from its section; its output is returned, never collected. */
prompt_file?: string; model?: PassModel;
/** The plan's model=cross-family (R65's risk reader or a numbers diff's one reader): the program runs it on the outside voice's model. */
cross_family?: boolean;
file?: string; sha256?: string; bytes?: number; chunks?: number; findings_sha256?: string;
/** With the base policy's review_exclude_paths active: its `:(top,exclude,glob)` items, shell-quoted (the fixture command's slot), and the kept files' `:(top,literal)` items. */
policy_exclude?: string; keep?: string[];
@@ -103,6 +106,8 @@ export function buildManifest(o: {
readers.push({ reader, stage, title: `ship ${o.pass} ${reader}`, state: 'planned', role, name, effort: d.effort, exclude, checklists: d.checklists,
...(policy.length ? { policy_exclude: policy.map(shellQuote).join(' ') } : {}), ...(d.keep?.length ? { keep: d.keep } : {}),
...(role === 'targeted' ? { classes: [...(o.targetedClasses ?? [])], diff_base: o.mergeBase ?? o.diffBase } : {}),
...(role === 'adversarial-native' && d.classes?.length ? { classes: [...d.classes], diff_base: o.mergeBase ?? o.diffBase } : {}),
...(d.model === 'cross-family' ? { cross_family: true } : d.model && d.model !== 'lane' ? { model: { model_id: d.model } } : {}),
...(role === 'red-team' && o.rangeBase ? { diff_base: o.rangeBase } : {}) });
}
for (const e of o.extras ?? []) {
@@ -176,14 +181,21 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor:
const specialists = m.readers.reduce((n, x) => n + (x.role === 'specialist' ? 1 : x.role === 'specialist-batched' ? (x.checklists ?? []).filter(c => c !== 'red-team').length : 0), 0);
Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) });
}
if (role === 'outside-voice') slots.diff = diffFor(m.diff_base, r);
let slice: Record<string, unknown> = {};
if (role === 'outside-voice') {
const s = sliceDiff(diffFor(m.diff_base, r), REVIEW_DIFF_MAX_BYTES);
const diffFile = path.join(briefs, 'outside-voice.diff');
fs.writeFileSync(diffFile, s.text, { mode: 0o600 });
Object.assign(slots, { diff_file: diffFile, diff_bytes: String(s.bytes), diff_sha256: createHash('sha256').update(s.text).digest('hex'), ...(s.sliced ? { diff_slice: sliceNote(s, REVIEW_DIFF_MAX_BYTES) } : {}) });
slice = { payload: { file: diffFile, sha256: slots.diff_sha256 }, ...(s.sliced ? { diff_slice: { kept: s.kept, files: s.files, bytes: s.bytes, total: s.total } } : {}) };
}
if (role === 'fix-check') {
const read = (f: string | undefined, what: string) => { if (!f) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read the fix-check ${what} ${f}`); } };
const fc = m.fix_check!;
Object.assign(slots, { fix_list: read(fc.fix_list, 'fix list'), hunks: read(fc.hunks, 'hunks'), tests: read(fc.tests, 'tests'), callers: read(fc.callers, 'callers') });
}
let targeted = {};
if (role === 'targeted') {
if (role === 'targeted' || (role === 'adversarial-native' && r.classes?.length)) {
const checklist = path.join(m.checklist_root, 'review/specialists/targeted.md');
let brief: string;
try { brief = fs.readFileSync(checklist, 'utf8'); } catch { throw new BriefError(`cannot read the targeted brief ${checklist}`); }
@@ -195,7 +207,7 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor:
fs.writeFileSync(file, text, { mode: 0o600 });
const sha = promptHash(text);
const bytes = Buffer.byteLength(text);
appendEvent(m.ledger, { event: 'rendered', reader: r.reader, role, name: r.name ?? null, file, sha256: sha, bytes, pass: m.pass, stage, workdir, tree, ...(findingsSha ? { findings_sha256: findingsSha } : {}), ...targeted, ...(disclosureDigest(m.disclosure) ? { exclude: disclosureDigest(m.disclosure) } : {}) });
appendEvent(m.ledger, { event: 'rendered', reader: r.reader, role, name: r.name ?? null, file, sha256: sha, bytes, pass: m.pass, stage, workdir, tree, ...(findingsSha ? { findings_sha256: findingsSha } : {}), ...targeted, ...slice, ...(disclosureDigest(m.disclosure) ? { exclude: disclosureDigest(m.disclosure) } : {}) });
Object.assign(r, { file, sha256: sha, bytes, chunks: chunkCount(text), state: 'rendered', tree, ...(findingsSha ? { findings_sha256: findingsSha } : {}) });
done.push(r);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* review-platform — the exact-head evidence that a platform review read a PR (design C6, FOUN-32 on founderstar#496).
* Capy's automatic review posts a pull-request review by capy-ai[bot] whose commit_id is the head it read; that review's
* id is the run id the plan's planned skip and the receipt's review_depth platform record. Anything else (no PR, no such
* review, a review at another commit, one that requested changes or was dismissed, gh unavailable) is no evidence, and the
* single light reader runs.
*/
import { spawnSync } from 'node:child_process';
export const PLATFORM_REVIEWER = 'capy-ai[bot]';
export interface PlatformReview { id: string; head: string; reviewer: string }
function gh(root: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('gh', args, { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
}
/** The newest submitted platform review of the PR (`pr`, or the current branch's) at `head`; `id`, when given, must be that review. */
export function platformReviewAt(root: string, head: string, o: { pr?: string; id?: string } = {}): { ok: true; review: PlatformReview } | { ok: false; reason: string } {
const view = gh(root, ['pr', 'view', ...(o.pr ? [o.pr] : []), '--json', 'number']);
const pr = view.ok ? (JSON.parse(view.out || '{}') as { number?: number }).number : undefined;
if (!pr) return { ok: false, reason: o.pr ? `gh cannot read PR ${o.pr}` : 'no PR for this branch yet' };
const list = gh(root, ['api', '--paginate', '--slurp', `repos/{owner}/{repo}/pulls/${pr}/reviews`]);
if (!list.ok) return { ok: false, reason: `gh cannot list the reviews of PR #${pr}` };
let reviews: Array<{ id?: number; user?: { login?: string }; commit_id?: string; state?: string }>;
try { reviews = (JSON.parse(list.out) as unknown[]).flat() as typeof reviews; } catch { return { ok: false, reason: `PR #${pr}'s reviews are not JSON` }; }
const at = reviews.filter(r => r.user?.login === PLATFORM_REVIEWER && r.state !== 'PENDING' && r.commit_id?.toLowerCase() === head.toLowerCase() && r.id !== undefined);
const newest = at.at(-1);
const hit = newest && (!o.id || String(newest.id) === o.id) && (newest.state === 'APPROVED' || newest.state === 'COMMENTED') ? newest : undefined;
if (!hit) return { ok: false, reason: o.id ? `review ${o.id} on PR #${pr} is not a ${PLATFORM_REVIEWER} review at ${head.slice(0, 12)}` : `no ${PLATFORM_REVIEWER} review of PR #${pr} at ${head.slice(0, 12)}` };
return { ok: true, review: { id: String(hit.id), head: head.toLowerCase(), reviewer: PLATFORM_REVIEWER } };
}
+53 -7
View File
@@ -12,7 +12,7 @@
*/
import { createHash } from 'node:crypto';
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const;
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] as const;
export type RiskClass = (typeof RISK_CLASSES)[number];
/** Metering and charge words (GST-850: gbrain-evals #289 changed a metering proxy and billing clients with none of the older tokens); they also count on a line. */
@@ -22,12 +22,23 @@ export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget'
export const MONEY_PAIRS: readonly (readonly [string, readonly string[]])[] = [
['usage', ['token', 'tokens', 'cost', 'costs', 'spend', 'billing', 'price', 'pricing', 'budget']], ['proxy', ['billing', 'bill']], ['client', ['cost', 'costs', 'charge', 'charges', 'billing', 'price', 'pricing', 'spend']],
];
/**
* Spend a run reserves, binds, leases or settles against a ledger, a budget or a cap (GST-1129: gstack-evals#170 bound paid spend
* with `bindCheck(ledger, …)`): a verb beside a spend word on one changed code line. Prose lines (PROSE_PATH) never count: skill
* text says "binds" and "ledger" in passing (#3698 and #3798 would have read as money).
*/
const SPEND_BIND = ['reserve', 'reserves', 'reserved', 'reservation', 'reservations', 'bind', 'binds', 'lease', 'leases', 'leased', 'settle', 'settles', 'settled'];
const SPEND_OF = ['ledger', 'ledgers', 'spend', 'budget', 'budgets', 'usd', 'cost', 'costs', 'credits', 'billing', 'cap', 'caps'];
export const SPEND_PAIRS: readonly (readonly [string, readonly string[]])[] = [...SPEND_BIND.map(w => [w, SPEND_OF] as const), ['cap', ['spend', 'usd', 'budget', 'ledger']]];
export const PROSE_PATH = /\.(md|mdx|markdown|txt|rst|adoc|tmpl)$/i;
/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */
export const MONEY_LINE = [
/\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i,
/\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/,
/\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/,
];
/** The MONEY_LINE entries a config file or results script still answers to: a spend cap and a paid-provider call site, never a bare _usd value. */
export const PAID_MONEY_LINE = MONEY_LINE.slice(2);
export const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles'];
export const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz'];
export const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i;
@@ -70,20 +81,45 @@ export const decodeCharRefs = (s: string) => s.replace(/&#(?:[xX]0*([0-9a-fA-F]+
return c <= 0x10ffff ? String.fromCodePoint(c) : '';
});
export const svgActive = (text: string) => { const d = decodeCharRefs(text); const squeezed = d.replace(/[\s\0-\x1f]+/g, ''); return SVG_ACTIVE.some(r => r.test(d) || r.test(squeezed)); };
/** Eval scorers, judges and graders (FOUN-32 §4): code that decides a published result is risk class eval, by path. */
const EVAL_PATH_TOKENS = ['scorer', 'scorers', 'scoring', 'judge', 'judges', 'grader', 'graders', 'grade', 'grading', 'rubric', 'rubrics'];
const DATA_PATH_TOKENS = ['pii', 'gdpr', 'ccpa', 'privacy', 'personal', 'account', 'accounts', 'customer', 'customers', 'cookie', 'cookies', 'persistence', 'storage', 'database'];
export const DATA_LINE = [
/\bDELETE\s+FROM\b/, /\b(?:drop\s+(?:table|column|database|schema)|truncate\s+table)\b/i, /\bdelete\s+from\s+[\w."`]+\s*(?:where\b|;|$|["'`)])/i,
/\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/,
];
/**
* Data and config files (HARN-1, harness-evals#231): judged by their path only, so a lease_usd value in a VM record never names money.
* A key in a config file still names a secret.
*/
export const CONFIG_DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|ya?ml|toml|ini|cfg|conf|env|properties|xml|plist)$/i;
/**
* Analysis scripts that live with their results (docs/benchmarks/.../results/cost-intervals.py): their path tokens and generic
* lines name no risk class, but a secret, a spend cap or a paid-provider call in one still does (PAID_MONEY_LINE).
*/
export const STATS_SCRIPT = /(^|\/)results\/(?:[^/]+\/)*[^/]+\.(py|r|jl|ipynb)$/i;
/**
* Docs that can print a credential (gbrain#8417: `mcp get gbrain` shows a stored bearer token): an Authorization header, a
* bearer value, a key-shaped literal, an environment key set to a value, or a get/show command for auth config. Read on the
* added lines of docs paths, which matchRisk never sees, and counted as security (FOUN-64 item 4).
*/
export const DOC_SECRET_LINE = [
/\bAuthorization\s*:\s*\S/i, /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/, /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}|\bgh[pousr]_[A-Za-z0-9]{20,}|\bxox[abposr]-[A-Za-z0-9-]{10,}|\bAKIA[0-9A-Z]{16}\b/,
new RegExp(String.raw`\b${KEY_NAME}\s*[=:]\s*['"]?[^\s'"$<{]{8,}`),
/\b(?:mcp|auth|config|credentials?|tokens?|secrets?|keys?|login)\s+(?:get|show|print|view|cat|token)\b/i, /\b(?:get|show|print|cat)\s+(?:\S+[ ./-])?(?:tokens?|auth|credentials?|secrets?|api[_-]?keys?|bearer)\b/i,
];
/** Whether a path or line's tokens name money: a metering or charge word (a cost word too, for a path), or a pair. */
const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x => words.includes(x)) || MONEY_PAIRS.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x)));
const pairs = (t: readonly string[], list: typeof MONEY_PAIRS) => list.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x)));
const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x => words.includes(x)) || pairs(t, MONEY_PAIRS);
/** A hash of the patterns that decide the risk classes, not of this file's bytes; lib/review-depth.ts folds it into DEPTH_RULES. */
export const RISK_RULES = createHash('sha256').update(JSON.stringify({
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS,
MONEY_LINE_TOKENS, MONEY_PAIRS,
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, SECURITY_JOINED, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS, EVAL_PATH_TOKENS,
MONEY_LINE_TOKENS, MONEY_PAIRS, SPEND_PAIRS,
patterns: Object.entries({ PROSE_PATH: [PROSE_PATH], DOC_SECRET_LINE, CONFIG_DATA_PATH: [CONFIG_DATA_PATH], STATS_SCRIPT: [STATS_SCRIPT], MONEY_LINE, PAID_MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, SECURITY_JOINED, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
})).digest('hex').slice(0, 16);
export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
@@ -95,6 +131,8 @@ export interface RiskInput {
removed?: Record<string, readonly string[]>;
/** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */
scopeAuth: boolean;
/** The base policy's review.risk_paths matcher: a code path it matches is risk class `policy`. */
isRiskPath?: (p: string) => boolean;
}
/** The risk classes the code paths touch, the first reason per class, in RISK_CLASSES order. Nothing fails closed here: input errors are the caller's. */
@@ -102,16 +140,24 @@ export function matchRisk(i: RiskInput): Map<RiskClass, string> {
const why = new Map<RiskClass, string>();
const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); };
for (const p of i.codePaths) {
const t = tokens(p);
if (i.isRiskPath?.(p)) hit('policy', 'risk_paths: ' + p);
const stats = STATS_SCRIPT.test(p);
const t = stats ? [] : tokens(p);
const config = CONFIG_DATA_PATH.test(p);
const pathOnly = config || stats;
const prose = PROSE_PATH.test(p);
if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p);
if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p);
if (namesMoney(t, MONEY_PATH_TOKENS)) hit('money', 'money path: ' + p);
if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p);
if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p);
if (!config && t.some(x => EVAL_PATH_TOKENS.includes(x))) hit('eval', 'eval scorer path: ' + p);
if (SVG_PATH.test(p) && [i.added?.[p], i.removed?.[p]].some(l => l && svgActive(l.join('\n')))) hit('security', 'active SVG lines: ' + p);
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p);
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p);
if (!why.has('money') && pathOnly && PAID_MONEY_LINE.some(r => r.test(line))) hit('money', 'spend cap or paid call: ' + p);
if (pathOnly) continue;
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS) || (!prose && pairs(tokens(line), SPEND_PAIRS)))) hit('money', 'money line: ' + p);
if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p);
if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p);
}
+3 -3
View File
@@ -21,7 +21,7 @@ import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_LINE_TOKENS, MONEY_PAIRS, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk';
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_LINE_TOKENS, MONEY_PAIRS, MONEY_PATH_TOKENS, PROSE_PATH, SECRET_LINE, SECRET_PATH, SPEND_PAIRS, matchRisk } from './review-risk';
export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const;
export type TargetedClass = (typeof TARGETED_CLASSES)[number];
@@ -31,8 +31,8 @@ export const secretLine = (line: string) => SECRET_LINE.some(r => r.test(line));
/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */
export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({
TARGETED_CLASSES, MONEY_PATH_TOKENS, MONEY_LINE_TOKENS, MONEY_PAIRS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
TARGETED_CLASSES, MONEY_PATH_TOKENS, MONEY_LINE_TOKENS, MONEY_PAIRS, SPEND_PAIRS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], PROSE_PATH: [PROSE_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
export interface TargetedInput {
+11 -5
View File
@@ -51,7 +51,7 @@ export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped
export interface ApiRun {
id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string;
path?: string; created_at?: string; run_started_at?: string; updated_at?: string;
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null;
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null; head_branch?: string | null;
}
export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null }
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown; skipped_files?: unknown }
@@ -77,7 +77,8 @@ export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | '
| 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error';
/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[] }
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
/** `fix`: the one command that clears a refusal the caller can repair, such as a pull_request run that tested a merge with an older base. */
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string; fix?: string };
const SHA = /^[0-9a-f]{40}$/;
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
@@ -145,7 +146,7 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
return errors;
}
const fail = (reason: RunRefusal, detail: string): RunVerdict => ({ ok: false, reason, detail });
const fail = (reason: RunRefusal, detail: string, fix?: string): RunVerdict => ({ ok: false, reason, detail, ...(fix ? { fix } : {}) });
/** When a run's newest attempt started. A run vetoes the candidate when it started at or after the second the candidate was created: a red re-run of an older run counts, and re-running the candidate does not hide a failure in between. */
const started = (r: ApiRun): string => r.run_started_at ?? r.created_at ?? '';
export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
@@ -209,12 +210,17 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
if (wrong.length) return fail('artifact_mismatch', `${at}'s ${p.artifact} records ${wrong.map(([k, got, want]) => `${k} ${String(got)} (expected ${want})`).join(', ')}`);
const headTree = deps.commitTree(run.head_sha);
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
if (rec.tree !== headTree) return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
const baseMoved = `gh workflow run ${p.workflow} --ref ${run.head_branch || '<branch>'}, then gstack-ci-carry wait`;
if (rec.tree !== headTree) {
const testedMerge = pr && typeof rec.sha === 'string' && SHA.test(rec.sha) && deps.commitTree(rec.sha) === rec.tree;
if (testedMerge) return fail('tree_mismatch', `${at} tested merge commit ${(rec.sha as string).slice(0, 12)} (tree ${String(rec.tree)}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base (the base moved after the run, or the branch lacks its tip)`, baseMoved);
return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
}
if (pr) {
if (typeof rec.sha !== 'string' || !SHA.test(rec.sha)) return fail('artifact_mismatch', `${at}'s ${p.artifact} names no merge commit (sha) for its pull_request run`);
const mergeTree = deps.commitTree(rec.sha);
if (!mergeTree) return fail('api_error', `git cannot read the tree of merge commit ${rec.sha}`);
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base`);
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base (the base moved after the run, or the branch lacks its tip)`, baseMoved);
}
const oses = Array.isArray(rec.os) ? rec.os : [rec.os];
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
+26
View File
@@ -0,0 +1,26 @@
/**
* ship-policy-review — the base-read `review` key of .gstack/ship-policy.json (docs/review-policy.md#review-depth),
* kept beside lib/ship-policy.ts so that module stays under the size ratchet. `panel` keeps today's reviewer panel
* for a class (risk by default: the printed full-depth plan and its full pass 2 on risk class, one reader elsewhere; R65 as amended by FOUN-64), `reader_model` is the model of that one reader,
* `platform` lets a non-risk diff rely on the platform review at its exact head, and `risk_paths` adds code
* paths to R51's risk classes.
*/
export interface ReviewPolicy { panel: 'off' | 'risk' | 'all'; reader_model: string; platform: 'off' | 'on'; risk_paths: string[] }
export const REVIEW_POLICY_DEFAULT: ReviewPolicy = { panel: 'all', reader_model: 'lane', platform: 'off', risk_paths: [] };
const MODEL = /^[A-Za-z0-9][\w./:-]{0,127}$/;
/** The key with every omitted field at its default (a policy file may set any subset). */
export function reviewPolicy(p: { review?: Partial<ReviewPolicy> | null } | null | undefined): ReviewPolicy {
return { ...REVIEW_POLICY_DEFAULT, ...(p?.review ?? {}) };
}
/** `review`: each field's error at its path; `globs` checks risk_paths as ship-policy checks every glob list. */
export function reviewErrors(value: unknown, out: { code: 'POLICY_INVALID'; path: string; message: string }[] | any[], globs: (key: string, v: unknown) => void): void {
if (!value || typeof value !== 'object' || Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'review', message: 'must be { panel?, reader_model?, platform?, risk_paths? }' }); return; }
const v = value as Record<string, unknown>;
for (const k of Object.keys(v)) if (!(k in REVIEW_POLICY_DEFAULT)) out.push({ code: 'POLICY_INVALID', path: `review.${k}`, message: 'unknown key' });
if ('panel' in v && !['off', 'risk', 'all'].includes(v.panel as string)) out.push({ code: 'POLICY_INVALID', path: 'review.panel', message: `must be off | risk | all, got ${JSON.stringify(v.panel)}` });
if ('platform' in v && !['off', 'on'].includes(v.platform as string)) out.push({ code: 'POLICY_INVALID', path: 'review.platform', message: `must be off | on, got ${JSON.stringify(v.platform)}` });
if ('reader_model' in v && (typeof v.reader_model !== 'string' || !MODEL.test(v.reader_model))) out.push({ code: 'POLICY_INVALID', path: 'review.reader_model', message: 'must be lane, cross-family or a model id (letters, digits and . _ / : -)' });
if ('risk_paths' in v) globs('review.risk_paths', v.risk_paths);
}
+9 -8
View File
@@ -17,6 +17,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
import { RESULT_CODES, type ResultCodeName } from './result-codes';
import { reviewErrors, type ReviewPolicy } from './ship-policy-review';
import { cmpVersion, parseVersion } from './version-source';
import { placeholderErrors, templateErrors } from './ship-policy-stamps';
export { VERSION_SLOT } from './ship-policy-stamps';
@@ -58,6 +59,7 @@ export interface ShipPolicy {
review_scope: ReviewScope;
/** Git glob pathspecs (root-anchored) whose data, docs, binary and log files /ship's reviewers leave out; read from the base only (docs/ship-policy.md#review-exclude-paths). */
review_exclude_paths: string[];
/** How much review /ship runs (docs/review-policy.md#review-depth): the panel fallback, the risk reader's model, platform review and extra risk paths; read from the base only. */ review: ReviewPolicy;
/** Consumed by the plan reviews (D1). */
pr_count: { default_pr_count: number; split_requires_owner: boolean };
/** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */
@@ -161,6 +163,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
stamp_sensitive_gates: [],
review_scope: 'frozen',
review_exclude_paths: [],
review: { panel: 'all', reader_model: 'lane', platform: 'off', risk_paths: [] },
pr_count: { default_pr_count: 1, split_requires_owner: true },
maintainers: [],
stamp_files: [],
@@ -194,6 +197,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.',
review_scope: 'frozen | diff-plus-blocking | all. frozen (default): /ship Step 9 fixes findings in the diff on pass 1, files blocking findings in code the PR did not change as issues, and later passes read only the range since the previous pass and fix only blocking findings; diff-plus-blocking: the same, but a blocking finding in code the PR did not change is fixed in this PR instead of filed; all: every finding is fixed now (the pre-1.91.249 loop).',
review_exclude_paths: 'Git glob pathspecs, root-anchored and case-sensitive (`receipts/**`, `**/*.jsonl`; at most 64, 256 characters each, no leading `:` or `!`): /ship\'s reviewers leave out the data, docs, binary and .log files they match. Read from the base branch only, so a branch cannot exclude its own files. Code, gate paths, agent prose, config, executables, .gstack/ and .github/ files, gate-tagged or secret-bearing files and files a kept code or test file names by full path stay reviewed (docs/ship-policy.md#review-exclude-paths).',
review: '{ panel?, reader_model?, platform?, risk_paths? }, read from the base branch only. panel: all (default: today\'s panel and loop everywhere) | risk (opt-in: today\'s panel at full depth on risk class, one reader on the rest) | off (one merged reader on risk class too); risk and off run one fix round. Their one-reader shape has not passed a no-loss replay yet (GSTA-258 C: FIX-METHOD). reader_model: the model of the one reader a lean depth runs, `lane` (default: the lane\'s own model, the native adversarial reader), `cross-family` or a model id. platform: off (default) | on: a docs or tests diff with no numeric, results or prereg claims whose PR carries the platform review (capy-ai[bot]) at the exact head skips /ship\'s readers (review_depth platform); never runtime code. risk_paths: globs of code paths that are risk class on top of R51\'s; data, docs and binary files under them stay out (docs/review-policy.md#review-depth).',
pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.',
maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.',
stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.',
@@ -244,6 +248,7 @@ export const BASE_RED_KEY_VERSION = '1.91.374.0';
export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0';
export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0';
export const REVIEW_POLICY_KEY_VERSION = '1.94.30.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
@@ -251,7 +256,7 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION,
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION,
};
@@ -277,15 +282,10 @@ export function minimumReader(raw: Record<string, unknown>): string {
export interface PolicyError { code: ResultCodeName; path: string; message: string }
const MAX_LIST = 64;
const MAX_PATTERN = 256;
const MAX_REGEX = 512;
const MAX_COMMAND = 512;
const MAX_LIST = 64, MAX_PATTERN = 256, MAX_REGEX = 512, MAX_COMMAND = 512;
const SHA_RE = /^[0-9a-f]{7,64}$/;
function hasDotDot(p: string): boolean {
return p.split(/[\\/]+/).some(seg => seg === '..');
}
const hasDotDot = (p: string): boolean => p.split(/[\\/]+/).some(seg => seg === '..');
/** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */
export function containedRelative(p: string): string | null {
@@ -482,6 +482,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
oneOf('history', ['merge-only', 'squash-ok']);
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors);
if ('review' in obj) reviewErrors(obj.review, errors, (k, v) => listErrors(k, v, errors, globError));
if ('release_tool' in obj && obj.release_tool !== null) {
const t = obj.release_tool;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
+7 -3
View File
@@ -24,6 +24,8 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
--timeout <s> Step 5's pregate --timeout for each runner process, 1 to 3600; unset, the base .gstack/pregate.json
timeout_s applies, else 600 s, and a longer lane is PREGATE_INCOMPLETE
gstack-ship-preflight --tests-only --base <base> --policy-from head --simulate [--allow-repo-commands] --lane <label>=<command>...
a dry run that proves a new ship_lanes_from mapping: which lanes the branch's own map would carry from a verified
CI run at this tree. Every line starts SIMULATION:; no lane runs, no evidence or ledger row is written and no
@@ -157,8 +159,8 @@ function merge(cwd: string, base: string, r: Report): boolean {
return true;
}
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, run: { concurrency: number; line: string }, r: Report): Promise<void> {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom], 3_600_000);
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, timeout: string | undefined, run: { concurrency: number; line: string }, r: Report): Promise<void> {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom, ...(timeout ? ['--timeout', timeout] : [])], 3_600_000);
const refusal = pg.status === 3 ? /\((PREGATE_[A-Z_]+|POLICY_[A-Z_]+)[;)]/.exec(pg.stderr)?.[1] : undefined;
const verdict = refusal ? `refused (${refusal})` : /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
r.add(`PREGATE: ${verdict}`);
@@ -223,6 +225,8 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
}
const policyFrom = flag('--policy-from') ?? 'base';
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
const timeout = flag('--timeout');
if (has('--timeout') && !(timeout && /^[1-9]\d*$/.test(timeout) && Number(timeout) <= 3600)) return { text: 'gstack-ship-preflight: --timeout needs whole seconds from 1 to 3600\n', exit: 2 };
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list, has('--allow-repo-commands')) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
@@ -231,7 +235,7 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
const r = new Report();
if (has('--tests-only') || integrate(cwd, base, r)) {
if (list.length) await tests(cwd, base, list, policyFrom, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
if (list.length) await tests(cwd, base, list, policyFrom, timeout, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
}
return { text: r.render(), exit: r.exit };
+4 -2
View File
@@ -47,7 +47,7 @@ function newestArtifact(ctx: QueueContext, slug: string, run: number, name: stri
return hits[0]!.expired ? 'unreadable' : hits[0]!.name;
}
interface Outcomes { panels?: Array<{ case?: string; status?: string; failsLane?: boolean; redClass?: string | null }>; flakyLines?: string[] }
interface Outcomes { panels?: Array<{ case?: string; status?: string; failsLane?: boolean; redClass?: string | null }>; failures?: unknown[]; flakyLines?: string[] }
/** Read one screen run: status, plan, gate binding, panels. */
export function screenVerdict(ctx: QueueContext, slug: string, run: number): ScreenOutcome {
@@ -78,7 +78,9 @@ export function screenVerdict(ctx: QueueContext, slug: string, run: number): Scr
if (!binding) return { kind: 'unknown', detail: `run ${run}'s ${bindingName} artifact is unreadable` };
const flaky = [...(binding.flaky_cases ?? [])].sort();
const flakyLines = Array.isArray(outcomes?.flakyLines) ? outcomes!.flakyLines!.filter((l): l is string => typeof l === 'string') : [];
const red = (outcomes?.panels ?? []).filter(p => typeof p.case === 'string' && (p.failsLane === true || (p.status === 'FAIL' && p.redClass === 'VERDICT'))).map(p => p.case!).sort();
const panelRed = (outcomes?.panels ?? []).filter(p => typeof p.case === 'string' && (p.failsLane === true || (p.status === 'FAIL' && p.redClass === 'VERDICT'))).map(p => p.case!);
const lineRed = (Array.isArray(outcomes?.failures) ? outcomes!.failures : []).flatMap(l => typeof l === 'string' ? /^✗ (\S+) {2}/.exec(l)?.[1] ?? [] : []);
const red = [...new Set([...panelRed, ...lineRed])].sort();
const base = `verdict ${binding.verdict}, reconcile exit ${binding.reconcile_exit}, run concluded ${view.conclusion ?? 'unknown'}${flaky.length ? `, flaky ${flaky.length}` : ''}`;
if (binding.verdict === 'GREEN' && binding.reconcile_exit === 0 && view.conclusion === 'success') return { kind: 'green', detail: base, flaky, flakyLines };
if (binding.verdict === 'RED' || binding.reconcile_exit !== 0 || red.length) return { kind: 'red', detail: base, red, flaky, flakyLines };
+50 -12
View File
@@ -25,7 +25,10 @@ import { collectCommit } from './review-collect';
import { reviewExclude, type Exclusion } from './review-exclude';
import { isUnavailable } from './ship-policy';
import { loadPolicyAt } from './ship-policy-at';
import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth';
import { depthOfCommitInput, gateScanNeeded, platformFits, scanNumbers, type Depth } from './review-depth';
import type { RiskClass } from './review-risk';
import { platformReviewAt } from './review-platform';
import { reviewPolicy, type ReviewPolicy } from './ship-policy-review';
import { TARGETED_CLASSES, classList, targetedClasses, type TargetedClass } from './review-targeted';
import { runScriptSync } from './script-invocation';
import { bindReceipt, type ReceiptBindingCode } from './ship-receipt';
@@ -33,7 +36,7 @@ import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
export type ReceiptDepth = NonNullable<ShipReceipt['review_depth']>;
export type ReviewLoopException = NonNullable<ShipReceipt['review_loop_exception']>;
const RANK: Record<Depth, number> = { light: 0, standard: 1, full: 2 };
const RANK: Record<Depth | 'platform', number> = { platform: -1, light: 0, standard: 1, full: 2 };
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v);
const sha = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v);
const BIN_DIR = path.join(import.meta.dir, '..', 'bin');
@@ -70,8 +73,15 @@ function exclusionAt(root: string, ref: string, baseSha: string | null, head: st
return reviewExclude({ root, baseSha, ref, mergeBase: c.mergeBase, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged, context });
}
/** Whether the base policy declares patterns, so the gate scan's per-path tags are worth reading. */
const declaresExclude = (root: string, ref: string, sha: string | null) => { if (!sha) return false; const l = loadPolicyAt(root, sha, ref); return !isUnavailable(l) && l.policy.review_exclude_paths.length > 0; };
/** The base policy's review_exclude_paths and review key (lib/ship-policy-review.ts), defaults when it cannot be read. */
function basePolicy(root: string, ref: string, sha: string | null): { declares: boolean; review: ReviewPolicy } {
const l = sha ? loadPolicyAt(root, sha, ref) : null;
const p = l && !isUnavailable(l) ? l.policy : null;
return { declares: !!p && p.review_exclude_paths.length > 0, review: reviewPolicy(p) };
}
const riskPathsOf = (r: ReviewPolicy) => (r.risk_paths.length ? { riskPaths: r.risk_paths } : {});
/** The mode the plan ran for this depth under the base policy's review.panel (lib/review-depth.ts planReview). */
const modeFor = (depth: Depth, r: ReviewPolicy): 'lean' | 'panel' => (r.panel === 'all' || (r.panel === 'risk' && depth !== 'light') ? 'panel' : 'lean');
/**
* The depth record for `head`: the range from git objects (collectCommit) and
@@ -84,8 +94,11 @@ export function receiptDepthAtHead(repoRoot: string, base: string, binDir: strin
return receiptReviewAtHead(repoRoot, base, binDir, head).depth;
}
/** receiptDepthAtHead plus the exclusion the head's review read under: the depth classifies excluded paths as docs (GST-508). */
export function receiptReviewAtHead(repoRoot: string, base: string, binDir: string, head: string): { depth: ReceiptDepth; exclude: Exclusion } {
/**
* receiptDepthAtHead plus the exclusion the head's review read under: the depth classifies excluded paths as docs (GST-508).
* `declared` (write --declare-risk, GST-1129) joins the detected risk classes and only raises; the record then keeps both lists.
*/
export function receiptReviewAtHead(repoRoot: string, base: string, binDir: string, head: string, declared: readonly RiskClass[] = []): { depth: ReceiptDepth; exclude: Exclusion } {
const branch = base.replace(/^origin\//, '');
const baseSha = baseCommit(repoRoot, `origin/${branch}`);
const baseRev = baseSha.ok ? baseSha.out : branch;
@@ -93,7 +106,7 @@ export function receiptReviewAtHead(repoRoot: string, base: string, binDir: stri
const sha = baseSha.ok ? baseSha.out : (git(repoRoot, ['rev-parse', '--verify', '--quiet', `${branch}^{commit}`]).out || null);
const c = collectCommit(repoRoot, baseRev, head);
const scope = scopeOfRange(repoRoot, binDir, baseRev, head);
const declares = declaresExclude(repoRoot, ref, sha);
const { declares, review } = basePolicy(repoRoot, ref, sha);
let tagged: number | null = 0;
let paths: Set<string> | null = declares ? new Set() : null;
if (declares || gateScanNeeded(c.paths, c.root)) {
@@ -104,8 +117,25 @@ export function receiptReviewAtHead(repoRoot: string, base: string, binDir: stri
paths = declares && tagged !== null ? taggedPaths(g?.stdout ?? '') : null;
}
const exclude = exclusionAt(repoRoot, ref, sha, head, c, paths, 'gstack-ship-receipt');
const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...(exclude.state === 'active' ? { excluded: exclude.excluded } : {}) });
return { depth: { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12), risk: d.risk }, exclude };
const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...riskPathsOf(review), declared, ...(exclude.state === 'active' ? { excluded: exclude.excluded } : {}) });
return { depth: { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12), risk: d.risk, mode: modeFor(d.depth, review), ...(d.declared.length ? { declared: d.declared, detected: d.detected } : {}) }, exclude };
}
/**
* `write --platform-review <id>` (design C6, FOUN-63): the receipt says honestly that no /ship reader ran because the platform
* review read this head. Allowed only under the base policy's review.platform on, on a light docs or tests diff with no numeric,
* results or prereg claims, with capy-ai[bot]'s review <id> on the PR at this exact head.
*/
export function platformReceiptDepth(repoRoot: string, base: string, depth: ReceiptDepth, head: string, id: string, pr?: string): { ok: true; depth: ReceiptDepth } | { ok: false; message: string } {
const branch = base.replace(/^origin\//, '');
const baseSha = baseCommit(repoRoot, `origin/${branch}`);
const { review } = basePolicy(repoRoot, baseSha.ok ? `origin/${branch}` : branch, baseSha.ok ? baseSha.out : null);
if (review.platform !== 'on') return { ok: false, message: `--platform-review needs review.platform on in ${branch}'s .gstack/ship-policy.json; with platform review off at least one /ship reader runs` };
const c = collectCommit(repoRoot, depth.base, head);
if (depth.depth !== 'light' || !platformFits(depth.class, c.paths, depth.runtime_lines, scanNumbers(c.added))) return { ok: false, message: `head ${head.slice(0, 12)} needs ${depth.depth} review (${depth.class} class, ${depth.runtime_lines} runtime lines): the platform depth covers only docs or tests diffs with no numeric, results or prereg claims` };
const found = platformReviewAt(repoRoot, head, { pr, id });
if (!found.ok) return { ok: false, message: `--platform-review ${id}: ${found.reason}` };
return { ok: true, depth: { ...depth, depth: 'platform', mode: 'platform', platform_review: found.review } };
}
/** The paths of the hunks a `gstack-gate-diff --format jsonl` run tagged. */
@@ -113,8 +143,8 @@ export function taggedPaths(jsonl: string): Set<string> {
return new Set(jsonl.split('\n').slice(1).flatMap(l => { try { const h = JSON.parse(l) as { path?: string; tags?: unknown[] }; return h.path && h.tags?.length ? [h.path] : []; } catch { return []; } }));
}
export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need];
export const deeper = (a: Depth, b: Depth): Depth => (RANK[a] >= RANK[b] ? a : b);
export const depthBelow = (have: Depth | 'platform', need: Depth | 'platform') => RANK[have] < RANK[need];
export const deeper = <D extends Depth | 'platform'>(a: D, b: D): D => (RANK[a] >= RANK[b] ? a : b);
/** `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the fix-check it followed. */
export function parseReviewLoopException(value: unknown): { ok: true; exception: ReviewLoopException } | { ok: false; message: string } {
@@ -254,10 +284,18 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip
const { baseSha, c, scope, x } = at ?? headInputs(root, baseRef, head, r);
const tagged = boundScan(r, head);
const scanBound = tagged !== null;
const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...(x.state === 'active' ? { excluded: x.excluded } : {}) });
const { review } = basePolicy(root, baseRef, baseSha.ok ? baseSha.out : null);
const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged, ...riskPathsOf(review), ...(x.state === 'active' ? { excluded: x.excluded } : {}) });
const have = rd?.depth;
const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`;
const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs} rounds=${r.review_loop?.rounds ?? (r.review_loop ? r.review_loop.passes + (r.review_loop.fix_checks ?? 0) : 'none')}`;
if (have === 'platform') {
const pr = rd!.platform_review;
const fits = need.depth === 'light' && platformFits(need.class, c.paths, need.runtimeLines, scanNumbers(c.added));
const ok = review.platform === 'on' && fits && !!pr && pr.head === head.toLowerCase();
if (ok) return { ok: true, line: `${line} platform_review=${pr!.id}@${pr!.head.slice(0, 12)}` };
return { ok: false, line, message: `the receipt records the platform depth, but ${review.platform !== 'on' ? `the base policy has review.platform ${review.platform}` : !fits ? `head ${head.slice(0, 12)} needs ${need.depth} review of a diff the platform depth does not cover` : `its platform review is not at head ${head.slice(0, 12)}`}; the receipt is unchanged; run /ship on ${head.slice(0, 12)}` };
}
if (have && !depthBelow(have, need.depth)) return { ok: true, line };
const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one';
const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; ');
+38 -1
View File
@@ -22,6 +22,7 @@ import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
import { resolveStateRoot } from './state-root';
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
import { waitForRun } from './ci-carry';
import { PREPUSH_CHECK_IDS } from './pregate/index';
import { buildRunCheck } from './pregate/run-check';
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
@@ -40,7 +41,8 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s
A lane the base branch maps in .gstack/pregate.json ship_lanes_from is carried from a verified CI run at this exact
tree, else deferred (EVIDENCE: DEFERRED): the pregate then runs its preflight stage here and its tests stage in accept
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved] [--early [--base <base>]]
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u;
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the pre-push housekeeping
guards (gstack-pregate --stage guards; a pass at HEAD's tree is reused), the idempotency check, git push -u;
--early (the Phase 2 push before review, so CI runs beside it) also runs pregate's secrets scan on the commits first
accept --base <base> --lane <label>=<command>... [--wait <s>]
after push: final-tree CI acceptance. Waits up to --wait seconds (default 170) for the CI run of each mapped profile at
@@ -503,6 +505,38 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
return r.next('Step 18: the receipt records ci_carried from the ledger');
}
/**
* The pre-push housekeeping guards (docs/pregate.md#prepush): `gstack-pregate --stage guards` before every push, early or
* final, so a stale golden, a size ratchet or a type error fails here instead of a CI round. A pass recorded at HEAD's
* exact tree by this step or by finish's preflight (both under .git, which no commit can carry) is reused, never rerun.
*/
function prepushGuards(cwd: string, branch: string, base: string | undefined): { line: string; stop?: string } {
const gitPath = (name: string) => path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', `gstack/${name}`]).stdout.trim() || `.git/gstack/${name}`);
const tree = git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim();
const out = gitPath('pregate-guards.json');
for (const file of [out, gitPath('pregate-finish-preflight.json')]) {
let rec: { tree?: string; checks?: { id: string; status: string; detail?: string }[] };
try { rec = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
const rows = PREPUSH_CHECK_IDS.map(id => rec.checks?.find(c => c.id === id));
if (tree && rec.tree === tree && rows.every(row => row?.status === 'pass')) return { line: `GUARDS: pass (reused from ${path.basename(file)} at tree ${tree.slice(0, 12)}: ${rows.map(row => `${row!.id} ${row!.detail ?? 'pass'}`).join('; ')})` };
}
fs.rmSync(out, { force: true });
const pg = bin(cwd, 'gstack-pregate', ['--stage', 'guards', '--allow-repo-commands', '--json', '--out', out, ...(base ? ['--base', base] : [])], 3_600_000);
let res: { status?: string; verdict?: string; tree?: string; code?: string; detail?: string; fix?: string; checks?: { id: string; status: string; detail?: string; fix?: string }[] } = {};
try { res = JSON.parse(pg.stdout.trim().split('\n').pop() ?? ''); } catch { /* unreadable: stops below */ }
const heads = res.status === 'refused' && res.code === 'POLICY_SOURCE_UNAVAILABLE' ? git(cwd, ['ls-remote', '--heads', 'origin'], 120_000) : null;
if (heads && heads.status !== 0) return { line: 'GUARDS: not run (origin unreachable)', stop: `cannot verify the remote branch (${firstLine(heads.stderr)}): restore access before pushing` };
if (heads && heads.stdout.split('\n').every(l => !l.trim() || l.trim().endsWith(`\trefs/heads/${branch}`))) return { line: `GUARDS: not run (origin has no branch but ${branch}, so no base to read guards from)` };
if (res.status === 'refused') return { line: `GUARDS: refused ${res.code}`, stop: `the pre-push guards could not run (${res.detail}; fix: ${res.fix}): repair it, then rerun push` };
const bad = (res.checks ?? []).filter(c => c.status !== 'pass' && c.status !== 'warn').map(c => `${c.id}: ${c.fix ?? c.detail ?? c.status}`).join('; ');
if (res.tree && res.tree !== tree) return { line: `GUARDS: not certified (they checked the working tree ${res.tree.slice(0, 12)}, not HEAD's tree ${tree.slice(0, 12)})`, stop: `the working tree differs from HEAD (uncommitted or untracked files): commit or stash them, then rerun push${bad ? `; the guards also reported ${bad}` : ''}` };
if (git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim() !== tree) return { line: 'GUARDS: not certified (HEAD moved while the guards ran)', stop: 'HEAD changed while the guards ran: rerun push' };
const verdict = res.verdict ?? `unreadable (exit ${pg.status}: ${firstLine(pg.stderr)})`;
const line = `GUARDS: ${verdict}${res.checks ? ` (${res.checks.map(c => `${c.id} ${c.detail ?? c.status}`).join('; ')})` : ''}`;
if (pg.status === 0 && /^(pass|warn)/.test(verdict)) return { line };
return { line, stop: `the pre-push guards did not pass (${bad || firstLine(pg.stderr || pg.stdout)}): fix it, commit, then rerun push` };
}
export function push(argv: readonly string[], cwd: string): Report {
const { flag, has } = flagReader(argv);
const r = new Report();
@@ -532,6 +566,9 @@ export function push(argv: readonly string[], cwd: string): Report {
r.add(`SECRETS: ${verdict} (early push)`);
if (scan.status !== 0 || !/^(pass|warn)/.test(verdict)) return r.stop(`the secrets scan did not pass before the early push (${firstLine(scan.stderr) || verdict}): remove the finding from the commits, then rerun push --early`);
}
const guards = prepushGuards(cwd, branch, flag('--base'));
r.add(guards.line);
if (guards.stop) return r.stop(guards.stop);
const local = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`);
+7 -1
View File
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { estimateTokens } from "./context-bill";
import { skillEvidence } from "./inventory";
export const DUE_PCT = 10;
@@ -28,6 +29,8 @@ export interface SkillBillReport {
rows: LevelRow[];
baseline: Baseline;
evalTasks?: EvalTasks | null;
/** gstack checkout only: the skill's row in docs/inventory/evidence.json (ETHOS.md: 9. Earn Its Place). */
evidence?: ReturnType<typeof skillEvidence>;
warnings: string[];
}
@@ -196,8 +199,10 @@ export function buildSkillBill(dir: string): SkillBillReport {
for (const f of onDemandFiles(dir)) rows.push(row("on-demand", dir, f));
const warnings: string[] = [];
let evalTasks: EvalTasks | null | undefined;
let evidence: SkillBillReport["evidence"];
if (kind === "gstack-checkout") {
const root = gstackRootOf(dir)!;
evidence = skillEvidence(root, dir);
const file = join(root, "test", "fixtures", "skill-evals", name, "tasks.json");
evalTasks = existsSync(file) ? { ...checkEvalTasks(file), path: `test/fixtures/skill-evals/${name}/tasks.json` } : null;
if (evalTasks?.problems.length) warnings.push(`EVAL_TASKS_INVALID: ${evalTasks.problems[0]}${evalTasks.problems.length > 1 ? ` (+${evalTasks.problems.length - 1} more)` : ""}. fix: the task format in docs/designs/SKILL_EVAL_CONTRACT.md`);
@@ -206,7 +211,7 @@ export function buildSkillBill(dir: string): SkillBillReport {
warnings.push("RENDER_STALE: SKILL.md.tmpl changed but SKILL.md did not. fix: bun run gen:skill-docs");
}
if (kind === "gstack-install") warnings.push("INSTALLED_GSTACK_SKILL: gstack-upgrade overwrites this folder. fix: edit <skill>/SKILL.md.tmpl in a gstack checkout, or copy the skill to a folder you own");
return { name, dir, kind, authored, rows, baseline: findBaseline(dir, name, authored), evalTasks, warnings };
return { name, dir, kind, authored, rows, baseline: findBaseline(dir, name, authored), evalTasks, evidence, warnings };
}
export function renderSkillBill(r: SkillBillReport): string {
@@ -217,6 +222,7 @@ export function renderSkillBill(r: SkillBillReport): string {
else lines.push(`BASELINE: ${b.status} (${b.reason})`);
if (r.evalTasks) lines.push(`EVAL TASKS: ${r.evalTasks.path} (${r.evalTasks.count} tasks)`);
else if (r.evalTasks === null) lines.push(`EVAL TASKS: none; a diet writes test/fixtures/skill-evals/${r.name}/tasks.json before any cut`);
if (r.evidence) lines.push(r.evidence.line);
lines.push(...r.warnings);
lines.push(`Commit line: Skill-Size: <before>→<after> bytes (${r.authored})`);
return lines.join("\n");
+58 -14
View File
@@ -267,23 +267,65 @@ export function skillLoads(t: Transcript): SkillLoad[] {
export const STATUS_RE = /^[ \t>*_-]*(?:\*\*|__)?(?:STATUS|Status)(?:\*\*|__)?[ \t]*(?:\*\*|__)?:[ \t]*(?:\*\*|__)?[ \t]*(DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)\b/m;
const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/;
const AGENT_TOOLS = /^(?:Agent|Task)$/;
const CHECK_RUNNER = /^(?:pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|tsc|make|cargo|go|bun|bunx|npm|npx|pnpm|yarn|node|deno|python3?|ruby|rake|mvn|gradlew?|dotnet|swift|xcodebuild|curl|mix|bazel|just|dart|flutter|rails)$/i;
const CHECK_SCRIPT = /(?:^|[-_.])(?:tests?|spec|repro|check|verify|lint)(?:[-_.]|$)/i;
const TEST_RUNNER = /^(?:pytest|py\.test|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|tsc|rake|playwright|cypress)$/i;
const CHECK_SCRIPT = /(?:^|[-_.:])(?:tests?|spec|repro|check|typecheck|verify|lint)(?:[-_.:]|$)/i;
const CHECK_VERB = /^(?:tests?|t|check|verify|spec|vet|clippy|nextest|lint|e2e)$/i;
const SUBCOMMAND_TOOL = /^(?:make|cargo|go|dotnet|swift|mix|bazel|just|dart|flutter|rails|mvn|gradlew?|xcodebuild|bun|npm|pnpm|yarn|deno)$/i;
const RUNS_PROGRAM = /^(?:cargo|go|dotnet|swift)$/i;
const PACKAGE_EXEC = /^(?:npx|bunx|pnpx)$/i;
const INTERPRETER = /^(?:node|python3?|ruby|php|perl|tsx|ts-node)$/i;
const FILE_INTERPRETER = /^(?:bun|deno)$/i;
const COMMAND_WRAPPER = /^(?:bundle|uv|poetry|pipenv|docker|podman|xvfb-run|bash|sh|zsh)$/;
const COMMAND_PREFIX = /^(?:env|sudo|time|nice|timeout|command|exec|\w+=\S*|-\S*|\d+[smh]?)$/;
const isCheckProgram = (word: string) => { const name = word.split(/[\\/]/).pop() ?? ''; return CHECK_RUNNER.test(name) || (name !== 'test' && CHECK_SCRIPT.test(name)); };
const SCRIPT_ARG = /[\\/]|\.(?:[cm]?[jt]sx?|py|rb|php|pl|sh)$/;
/** Options whose next word is their value (`make -C web test`, `pnpm --filter app test`, `xcodebuild -scheme App test`). */
const VALUE_FLAG = /^(?:-[CfpwF]|--(?:prefix|filter|cwd|dir|directory|workspace|package|manifest-path|project|file)|-(?:scheme|project|workspace|destination|configuration|target|sdk))$/;
const WRAPPER_DEPTH = 3;
const baseName = (word: string) => word.split(/[\\/]/).pop() ?? '';
const isCheckScript = (word: string) => { const name = baseName(word); return name !== 'test' && CHECK_SCRIPT.test(name); };
/** The words after the program that are not options or option values. */
function positionals(words: string[]): string[] {
const out: string[] = [];
for (let i = 1; i < words.length; i++) {
if (words[i] === '--') break;
if (!words[i].startsWith('-')) out.push(words[i]);
else if (VALUE_FLAG.test(words[i])) i++;
}
return out;
}
/**
* One command's words (prefixes removed): does it run tests, a check, or a repro? Installs, builds and version probes
* do not. Wrappers nest at most WRAPPER_DEPTH deep, so a long chain of wrapper words costs linear time, not exponential.
*/
function runsCheck(words: string[], depth = 0): boolean {
const program = baseName(words[0] ?? '');
const args = positionals(words);
if (TEST_RUNNER.test(program) || isCheckScript(words[0] ?? '')) return true;
if (depth < WRAPPER_DEPTH && PACKAGE_EXEC.test(program)) return runsCheck(words.slice(1).filter(w => !w.startsWith('-')), depth + 1);
if (COMMAND_WRAPPER.test(program)) return depth < WRAPPER_DEPTH && [1, 2, 3, 4, 5].some(i => i < words.length && runsCheck(words.slice(i), depth + 1));
if (INTERPRETER.test(program)) return args.length > 0 || words.slice(1).some(w => /^-[ecm]$|^--test$/.test(w));
if (program === 'curl') return args.some(a => /^https?:\/\/|^localhost|^127\./.test(a));
if (FILE_INTERPRETER.test(program) && args[0] && SCRIPT_ARG.test(args[0])) return true;
if (!SUBCOMMAND_TOOL.test(program) || !args[0]) return false;
const [verb, next] = args;
if (CHECK_VERB.test(verb) || isCheckScript(verb) || args.some(a => CHECK_VERB.test(a))) return true;
if (verb === 'run' || verb === 'exec' || verb === 'x') return RUNS_PROGRAM.test(program) || (!!next && (CHECK_VERB.test(next) || isCheckScript(next) || TEST_RUNNER.test(baseName(next)) || SCRIPT_ARG.test(next)));
return /^(?:yarn|pnpm)$/i.test(program) && TEST_RUNNER.test(verb);
}
const SAFE_SPECIALIST = /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,39}$/;
/**
* A test or repro run: some segment of the command (split on && || ; | and newlines) starts with a test runner or a
* test, check or repro script, directly or behind a wrapper (bundle exec, uv run, docker compose run, xvfb-run, bash).
* A test or repro run: some segment of the command (split on && || ; | and newlines) runs a test runner, a test verb
* (`npm test`, `go test`, `make check`), a test, check or repro script, or code through an interpreter, directly or
* behind a wrapper (bundle exec, uv run, docker compose run, xvfb-run, bash). Installs, builds and version probes do not count.
*/
export function isCheckCommand(cmd: string): boolean {
return cmd.split(/&&|\|\||[;|\n]/).some(segment => {
const words = segment.trim().replace(/^[({]\s*/, '').split(/\s+/);
while (words.length > 1 && COMMAND_PREFIX.test(words[0])) words.shift();
if (isCheckProgram(words[0])) return true;
return COMMAND_WRAPPER.test(words[0].split(/[\\/]/).pop() ?? '') && words.slice(1, 6).some(isCheckProgram);
return runsCheck(words);
});
}
const WAIVE_RE = /gstack-skill-contract["']?\s+waive\s+["']?([A-Za-z0-9][A-Za-z0-9_.-]*)["']?/;
@@ -495,8 +537,11 @@ export function lookupContract(skill: string, skillsDir: string, skillFile?: str
return 'error' in parsed ? { skill, file, error: `${file}: ${parsed.error}`, reason: 'invalid' } : { skill, file, contract: parsed.contract };
}
export type Outcome = 'met' | 'met_waived' | 'blocked' | 'met_after_block' | 'miss';
export interface RunRecord { skill: string; load_line: number; outcome: Outcome; missing: string[]; waived: string[]; blocked_at?: string; updated_at: string }
export type Outcome = 'met' | 'met_waived' | 'blocked' | 'met_after_block' | 'miss' | 'deferred';
/** deferred_agents: the Agent dispatches still running at the last stop the hook let through unchecked; deferred_at: when that set was first seen. */
export interface RunRecord { skill: string; load_line: number; outcome: Outcome; missing: string[]; waived: string[]; blocked_at?: string; deferred_agents?: string[]; deferred_at?: string; updated_at: string }
/** The same Agent dispatches still running this long after the stop that first saw them count as stalled. */
export const AGENT_STALL_MS = 10 * 60 * 1000;
export interface SessionRecord { version: 1; session_id: string; transcript_path: string; runs: Record<string, RunRecord> }
export function recordsDir(env: NodeJS.ProcessEnv = process.env): string {
@@ -562,16 +607,15 @@ export function bumpCanary(why: string, env: NodeJS.ProcessEnv = process.env): n
export interface ReportRow { session: string; skill: string; outcome: Outcome | 'fixed_after_block'; missing: string[]; waived: string[]; transcript: string }
/** Every recorded run; a run still marked blocked is re-checked against its transcript when a recheck is given. */
/** Every recorded run; a run still marked blocked or deferred is re-checked against its transcript when a recheck is given. */
export function reportRows(records: SessionRecord[], recheck?: (rec: SessionRecord, run: RunRecord) => CheckResult | null): ReportRow[] {
return records.flatMap(rec => Object.values(rec.runs).map(run => {
const row: ReportRow = { session: rec.session_id, skill: run.skill, outcome: run.outcome, missing: run.missing, waived: run.waived, transcript: rec.transcript_path };
if (run.outcome !== 'blocked' || !recheck) return row;
if ((run.outcome !== 'blocked' && run.outcome !== 'deferred') || !recheck) return row;
const again = recheck(rec, run);
if (!again) return row;
return again.met
? { ...row, outcome: 'fixed_after_block' as const, missing: [], waived: again.waived }
: { ...row, outcome: 'miss' as const, missing: again.missing, waived: again.waived };
if (!again.met) return { ...row, outcome: 'miss' as const, missing: again.missing, waived: again.waived };
return { ...row, outcome: run.outcome === 'blocked' ? 'fixed_after_block' as const : 'met' as const, missing: [], waived: again.waived };
}));
}
+25 -4
View File
@@ -34,6 +34,12 @@ export const SWITCH_ENV: Record<SwitchName, string> = {
skill_contracts: 'GSTACK_SKILL_CONTRACTS',
};
/** Each switch's one default. setup, gstack-config and gstack-skill-route read it through `gstack-skill-route --default <switch>`; the hooks read it here. */
export const SWITCH_DEFAULT: Record<SwitchName, 'on' | 'off'> = {
skill_routing: 'off',
skill_contracts: 'off',
};
export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' | null {
const v = String(raw ?? '').trim().toLowerCase();
if (['on', 'yes', 'y', 'true', '1'].includes(v)) return 'on';
@@ -43,18 +49,33 @@ export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' |
export interface SwitchReading { on: boolean; source: string; invalid?: string }
/** env GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS > config key > default off. An unrecognized value is off. */
/** A config.yaml that exists but cannot be read as a file: the person's choice is unknown. */
function configUnreadable(env: NodeJS.ProcessEnv): boolean {
const file = path.join(resolveStateRoot(env), 'config.yaml');
try {
fs.accessSync(file, fs.constants.R_OK);
return !fs.statSync(file).isFile();
} catch (e) {
return (e as NodeJS.ErrnoException)?.code !== 'ENOENT';
}
}
/**
* env GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS > config key > SWITCH_DEFAULT. An unrecognized value takes the
* default, as setup does. An unreadable config is off whatever the default: it may hold an off the person set.
*/
export function readSkillSwitch(name: SwitchName, env: NodeJS.ProcessEnv = process.env): SwitchReading {
const fallback = SWITCH_DEFAULT[name] === 'on';
const envName = SWITCH_ENV[name];
const raw = env[envName];
if (raw !== undefined && raw !== '') {
const v = normalizeSwitch(raw);
return v ? { on: v === 'on', source: `env ${envName}` } : { on: false, source: `env ${envName}`, invalid: raw };
return v ? { on: v === 'on', source: `env ${envName}` } : { on: fallback, source: `env ${envName}`, invalid: raw };
}
const cfg = readConfigKey(name, env);
if (cfg === null) return { on: false, source: 'default' };
if (cfg === null) return configUnreadable(env) ? { on: false, source: 'config unreadable' } : { on: fallback, source: 'default' };
const v = normalizeSwitch(cfg);
return v ? { on: v === 'on', source: `config ${name}` } : { on: false, source: `config ${name}`, invalid: cfg };
return v ? { on: v === 'on', source: `config ${name}` } : { on: fallback, source: `config ${name}`, invalid: cfg };
}
/** Where Claude Code reads skills: GSTACK_INSTALLED_SKILLS_DIR, else ${CLAUDE_CONFIG_DIR:-~/.claude}/skills. */
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.94.24",
"version": "1.94.34",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
@@ -77,7 +77,7 @@
"html-to-docx": "1.8.0",
"html-to-vdom": "0.7.0",
"marked": "^18.1.1",
"playwright": "^1.62.1",
"playwright": "^1.64.1",
"sharp": "^0.35.4",
"socks": "^2.8.10",
"xterm": "^5.3.0",
@@ -115,6 +115,6 @@
"sharp": "0.35.5"
},
"patchedDependencies": {
"playwright-core@1.62.1": "patches/playwright-core@1.62.1.patch"
"playwright-core@1.64.1": "patches/playwright-core@1.64.1.patch"
}
}
@@ -1,8 +1,8 @@
diff --git a/lib/coreBundle.js b/lib/coreBundle.js
index d9041dd0f009072511a7d808dec83121ff29e127..542acd31dd7e8ae084de952dc42ec63d0caba849 100644
index 6893f1cf133efec2c5dccc35c77cc7a53870ccbc..7990e202c5b574ca5ea4c3e73c15c2da43a5b980 100644
--- a/lib/coreBundle.js
+++ b/lib/coreBundle.js
@@ -8906,6 +8906,10 @@ async function launchProcess(options) {
@@ -10031,6 +10031,10 @@ async function launchProcess(options) {
env: options.env,
cwd: options.cwd,
shell: options.shell,
@@ -13,16 +13,7 @@ index d9041dd0f009072511a7d808dec83121ff29e127..542acd31dd7e8ae084de952dc42ec63d
stdio
};
const spawnedProcess = childProcess.spawn(options.command, options.args || [], spawnOptions);
@@ -8985,7 +8989,7 @@ async function launchProcess(options) {
options.log(`[pid=${spawnedProcess.pid}] <will force kill>`);
try {
if (process.platform === "win32") {
- const taskkillProcess = childProcess.spawnSync(`taskkill /pid ${spawnedProcess.pid} /T /F`, { shell: true });
+ const taskkillProcess = childProcess.spawnSync(`taskkill /pid ${spawnedProcess.pid} /T /F`, { shell: true, windowsHide: true });
const [stdout2, stderr2] = [taskkillProcess.stdout.toString(), taskkillProcess.stderr.toString()];
if (stdout2)
options.log(`[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout2}`);
@@ -34634,7 +34638,10 @@ var init_chromiumSwitches = __esm({
@@ -36204,7 +36208,10 @@ var init_chromiumSwitches = __esm({
// Disables forced sign-in in Edge.
"msForceBrowserSignIn",
// Disables updating the preferred version in LaunchServices preferences on mac.
+5 -2
View File
@@ -10,7 +10,8 @@ import { isPaidTestFile } from '../../test/helpers/paid-test-set';
import { PERIODIC_CI_EXCLUDE } from '../../test/helpers/periodic-exclude-data';
import { FILE_RETRY_BUDGETS } from '../../test/helpers/eval-budgets';
import { OVERLAY_MIN_FILE_WALL_MS } from '../../test/helpers/overlay-case-policy';
import { packageChangeOnlyVersion, prProfileFileCases, selectPrProfile, type PrProfileSelection } from '../test-pr-profile';
import { PR_PROFILE_MAPS, packageChangeOnlyVersion, prProfileFileCases, selectPrProfile, type PrProfileSelection } from '../test-pr-profile';
import { derivedDependencies } from '../pr-dependencies';
import { detectBaseBranch, getChangedFiles, selectTests, E2E_TOUCHFILES, E2E_TIERS, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from '../../test/helpers/touchfiles';
import { caseTestNamePattern, fileCaseRegistration, shardCaseId, shardFile, shardTrial } from './paid-cases';
import { DEFAULT_MAX_FILES_PER_SHARD, DEFAULT_SHARD_TIMEOUT_MS, DEFAULT_WITHIN_SHARD_CONCURRENCY, PAID_TIERS, ROOT, isOverlayTestFile,
@@ -249,8 +250,10 @@ export function computePaidCaseSelection(options: {
: selectTests(selectionFiles, table, GLOBAL_TOUCHFILES, { baseRef, cwd: rootDir }).selected;
const selection = { e2e: select(E2E_TOUCHFILES), judges: select(LLM_JUDGE_TOUCHFILES) };
if (options.profile === 'full') return { selection, reason: all ? 'run-all' : 'diff' };
// The changed files are paths in the checkout under review, which can be newer than the tree this selector runs from.
const readSource = (file: string) => { try { return fs.readFileSync(path.join(rootDir, file), 'utf8'); } catch { return ''; } };
const coverage = selectPrProfile({ selectedE2E: selection.e2e, selectedJudges: selection.judges, changedFiles: effectiveFiles, sourceAliases,
screenPolicy: env.EVALS_SCREEN_POLICY === 'narrow' ? 'narrow' : 'broad' });
derived: derivedDependencies(PR_PROFILE_MAPS, rootDir), readSource, screenPolicy: env.EVALS_SCREEN_POLICY === 'narrow' ? 'narrow' : 'broad' });
if (coverage.needsFullValidation) {
throw new Error(`PR profile requires full validation: ${coverage.missingCoverage.join(', ')}. Use --profile full and the relevant periodic cases.`);
}
+1 -1
View File
@@ -81,7 +81,7 @@ function newShip(f: Fixture, sc: Scenario): { calls: Call[]; ending: string } {
let adversarialRan = false;
for (let i = 0; i < 6; i++) {
const plan = planReview({ ...base(f), history: h, criticalFound: sc === 'blocker' && i === 0, adversarialRan });
if (plan.next.step === 'done' || plan.next.step === 'stop') { ending = plan.next.step; break; }
if (plan.next.step === 'done' || plan.next.step === 'stop' || plan.next.step === 'platform') { ending = plan.next.step; break; }
const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION;
for (const d of plan.dispatches) calls.push({ role: d.role, name: d.name, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes });
adversarialRan ||= plan.dispatches.some(d => d.role === 'adversarial-native');
-1
View File
@@ -41,7 +41,6 @@
"browse/test/handoff.test.ts\tTS2554\tExpected 4 arguments, but got 3.": 2,
"browse/test/pair-agent-e2e.test.ts\tTS2532\tObject is possibly 'undefined'.": 1,
"browse/test/pty-inject-scan.test.ts\tTS2345\tArgument of type '{ Authorization?: undefined; } | { Authorization: string; }' is not assignable to parameter of type 'Record<string, string>'. Type '{ Authorization?: undefined; }' is not assignable to type 'Record<string, string>'. Property 'Authorization' is incompatible with index signature. Type 'undefined' is not assignable to type 'string'.": 1,
"browse/test/security-audit-r2.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Type '{ isClosed: () => boolean; url: () => string; }' is missing the following properties from type 'Page': evaluate, evaluateHandle, addInitScript, $, and 110 more.": 1,
"browse/test/server-auth.test.ts\tTS2345\tArgument of type '{ Host: string; Origin: string; } | { Host?: undefined; Origin: string; } | { Authorization: string; Host: string; Origin?: undefined; }' is not assignable to parameter of type 'Record<string, string>'. Type '{ Host?: undefined; Origin: string; }' is not assignable to type 'Record<string, string>'. Property 'Host' is incompatible with index signature. Type 'undefined' is not assignable to type 'string'.": 1,
"browse/test/server-factory.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type '\"local\"' is not assignable to parameter of type 'undefined'.": 1,
"browse/test/server-proxy-fail-fast.test.ts\tTS2339\tProperty 'subarray' does not exist on type 'string | NonSharedBuffer'. Property 'subarray' does not exist on type 'string'.": 1,
+21 -10
View File
@@ -53,7 +53,7 @@ Options:
-q, --quiet Suppress progress output.
-h, --help Show this help and exit.
Claude Code hooks (off by default; docs/skill-routing-and-contracts.md):
Claude Code hooks (docs/skill-routing-and-contracts.md):
--skill-routing / --no-skill-routing
Name the gstack skill a prompt fits, in one line of
context (UserPromptSubmit). Saved as skill_routing.
@@ -4882,32 +4882,42 @@ if [ "$NO_TEAM_MODE" -ne 1 ] && [ "$TL_DECISION" != "no" ] && [ -x "$SETTINGS_HO
fi
# ─── Skill routing + contract hooks (GST-1088) ───────────────────────────────
# Two opt-in Claude Code hooks, both OFF by default (docs/skill-routing-and-contracts.md):
# Two Claude Code hooks (docs/skill-routing-and-contracts.md):
# skill_routing — UserPromptSubmit: one line naming the gstack skill a prompt fits.
# skill_contracts — Stop: blocks once when a contract skill ends without its outputs.
# Gate per hook, mirroring the timeline_stop_hook block above: flag > env
# (GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS) > saved config (skill_routing /
# skill_contracts) > default off. An explicit flag persists to config; a typo
# warns and is not persisted; off (or --no-team) removes a live registration.
# skill_contracts) > the switch's default, SWITCH_DEFAULT in lib/skill-route.ts,
# read through gstack-skill-route --default (off if that cannot run). An
# explicit flag persists to config; a typo warns, takes the default and is not
# persisted; off (or --no-team) removes a live registration.
# The hooks read the same switch at run time, so `gstack-config set <key> off`
# takes effect on the next prompt without re-running setup.
_skill_hook_gate() {
local key="$1" mode="$2" env_name="$3" event="$4" cmd="$5" source="$6" timeout="$7" what="$8"
local decision src out unrecognized=0
local decision src out def unrecognized=0
def="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SKILL_ROUTE_BIN" --default "$key" 2>/dev/null || true)"
[ "$def" = "on" ] || def="off"
if [ -n "$mode" ]; then
decision="$mode"; src="flag"
elif [ -n "${!env_name:-}" ]; then
decision="${!env_name}"; src="env $env_name"
else
decision="$("$GSTACK_CONFIG" get "$key" 2>/dev/null || true)"; src="config $key"
decision="$("$GSTACK_CONFIG" get --stored "$key" 2>/dev/null || true)"; src="config $key"
if [ "$decision" = "unset" ]; then decision=""; src="default"; fi
if [ "$decision" = "unreadable" ]; then
log " WARNING: cannot read $key from config — using 'off' for this run whatever the default; fix config.yaml, then re-run setup"
decision="off"
fi
fi
decision=$(printf '%s' "$decision" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$decision" in
on|yes|y|true|1) decision="on" ;;
off|no|n|false|skip|0|"") decision="off" ;;
on|yes|y|true|1) decision="on" ;;
off|no|n|false|skip|0) decision="off" ;;
"") decision="$def" ;;
*)
log " WARNING: unrecognized $key value '$decision' (from $src) — using default 'off' for this run, not persisting"
decision="off"; unrecognized=1 ;;
log " WARNING: unrecognized $key value '$decision' (from $src) — using default '$def' for this run, not persisting"
decision="$def"; unrecognized=1 ;;
esac
if [ -n "$mode" ] && [ "$unrecognized" -eq 0 ]; then
"$GSTACK_CONFIG" set "$key" "$decision" >/dev/null 2>&1 || true
@@ -4929,6 +4939,7 @@ _skill_hook_gate() {
log " warning: settings hook update failed: $(printf '%s\n' "$out" | head -1) — run $SETTINGS_HOOK manually"
fi
}
SKILL_ROUTE_BIN="$SOURCE_GSTACK_DIR/bin/gstack-skill-route"
SKILL_ROUTE_HOOK="$(_hook_command_path hosts/claude/hooks/skill-route-hook || true)"
SKILL_CONTRACT_HOOK="$(_hook_command_path hosts/claude/hooks/skill-contract-stop-hook || true)"
_skill_hook_gate skill_routing "$SKILL_ROUTING_MODE" GSTACK_SKILL_ROUTING UserPromptSubmit \
+1 -1
View File
@@ -109,7 +109,7 @@ One re-audit at most.
Commit in bisectable chunks; `gstack-ship-release finish --base <base> --reviewed-tree <t> --reviewed-base <b>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `GUARDS:`, `PUSH:`
(pushed, ALREADY_PUSHED). `EVIDENCE: DEFERRED`: the `accept` its `NEXT:` names; final-head
repo gates run beside CI. Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
+1 -1
View File
@@ -107,7 +107,7 @@ One re-audit at most.
Commit in bisectable chunks; `gstack-ship-release finish --base <base> --reviewed-tree <t> --reviewed-base <b>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `GUARDS:`, `PUSH:`
(pushed, ALREADY_PUSHED). `EVIDENCE: DEFERRED`: the `accept` its `NEXT:` names; final-head
repo gates run beside CI. Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
+2 -1
View File
@@ -4,7 +4,8 @@
`DISPATCH: adversarial-native` and the outside voice only on
`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0;
above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`;
with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader
with neither line, take decision 3 of Finish the adversarial phase. The native pass takes its line's
`checklists=` and `classes=` as render flags and runs on its `model=` (`lane`: yours). On `DISPATCH: targeted`, also run the targeted reader
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap,
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
and add `--reservation <id>` to the `--start adversarial-review` call below.
+2 -1
View File
@@ -2,7 +2,8 @@
`DISPATCH: adversarial-native` and the outside voice only on
`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0;
above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`;
with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader
with neither line, take decision 3 of Finish the adversarial phase. The native pass takes its line's
`checklists=` and `classes=` as render flags and runs on its `model=` (`lane`: yours). On `DISPATCH: targeted`, also run the targeted reader
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap,
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
and add `--reservation <id>` to the `--start adversarial-review` call below.
+8 -3
View File
@@ -17,7 +17,9 @@ Finish the complete review and QA before applying any fix in Step 9.4.
**Review plan.** Pass 1 runs `~/.claude/skills/gstack/bin/gstack-review-plan
--base <base>`, plus `--depth-floor <d>` on every plan run when the /ship prompt
gave `--depth-floor <d>` (it only raises); a repeat uses the plan run that picked it.
gave `--depth-floor <d>` (it only raises); add `--declare-risk <classes>` when the diff touches a risk
class the plan's `REVIEW_DEPTH:` misses (spend it reserves or binds: `money`), and give Step 16's
receipt write the same flag; a repeat uses the plan run that picked it.
A `scoped` repeat is a full pass on the range: its `fix-check` is decision 3.5's
reviewer and prompt with `git diff $RANGE_BASE` as the hunks, inside this pass.
Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth,
@@ -27,6 +29,9 @@ family (`full` is the lane's reasoning, `medium` reasoning medium, `light` low;
host that cannot set it runs its default) and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules;
`specialist batched` is one subagent reading every checklist its
`checklists=` names (light adds Red Team), tagging each finding with its checklist.
`NEXT: platform` runs no reader: log item 6's row with `"depth":"platform"` and the
`PLATFORM_REVIEW:` id (Step 16's receipt write takes `--platform-review <id>`; Step 11.5 selects no Step 11
record), then go to Step 10.
A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at
`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds
`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`,
@@ -825,9 +830,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan -
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
`--depth-floor <d>` when given, `--grant <approval id>` once the owner's grant of one scoped fix-check is
`--depth-floor <d>` when given, `--fix-checks <n>` after a fix-check, `--grant <approval id>` once the owner's grant of one scoped fix-check is
in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop.
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4, `platform` 4. `voices` 4: Step 11 runs before the stop.
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
Other scopes apply decisions 2–4 in order.
+8 -3
View File
@@ -15,7 +15,9 @@ Finish the complete review and QA before applying any fix in Step 9.4.
**Review plan.** Pass 1 runs `~/.claude/skills/gstack/bin/gstack-review-plan
--base <base>`, plus `--depth-floor <d>` on every plan run when the /ship prompt
gave `--depth-floor <d>` (it only raises); a repeat uses the plan run that picked it.
gave `--depth-floor <d>` (it only raises); add `--declare-risk <classes>` when the diff touches a risk
class the plan's `REVIEW_DEPTH:` misses (spend it reserves or binds: `money`), and give Step 16's
receipt write the same flag; a repeat uses the plan run that picked it.
A `scoped` repeat is a full pass on the range: its `fix-check` is decision 3.5's
reviewer and prompt with `git diff $RANGE_BASE` as the hunks, inside this pass.
Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth,
@@ -25,6 +27,9 @@ family (`full` is the lane's reasoning, `medium` reasoning medium, `light` low;
host that cannot set it runs its default) and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules;
`specialist batched` is one subagent reading every checklist its
`checklists=` names (light adds Red Team), tagging each finding with its checklist.
`NEXT: platform` runs no reader: log item 6's row with `"depth":"platform"` and the
`PLATFORM_REVIEW:` id (Step 16's receipt write takes `--platform-review <id>`; Step 11.5 selects no Step 11
record), then go to Step 10.
A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at
`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds
`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`,
@@ -234,9 +239,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan -
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
`--depth-floor <d>` when given, `--grant <approval id>` once the owner's grant of one scoped fix-check is
`--depth-floor <d>` when given, `--fix-checks <n>` after a fix-check, `--grant <approval id>` once the owner's grant of one scoped fix-check is
in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop.
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4, `platform` 4. `voices` 4: Step 11 runs before the stop.
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
Other scopes apply decisions 2–4 in order.
+105 -13
View File
@@ -1,7 +1,9 @@
import { expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { closePacketReadAfterNotice, dispatchPromptDelta, prepareGuardSession, stoppedAtPhaseBoundary } from './helpers/autoplan-guard-pty';
import { closePacketReadAfterNotice, dispatchEventRewriter, dispatchPromptDelta, dispatchToken, prepareGuardSession, startDispatchRelay, stoppedAtPhaseBoundary } from './helpers/autoplan-guard-pty';
import { codeOf, guardFixture } from './helpers/autoplan-guard-fixture';
const base = '/home/runner/.cache/gstack-paid-shard-x/tmp/ap-guard-pty-y';
@@ -27,10 +29,10 @@ test('the scripted actor ends its turn after a background CEO dispatch and reads
try {
const skill = fs.readFileSync(path.join(s.project, '.claude', 'skills', fs.readdirSync(path.join(s.project, '.claude', 'skills'))[0]!, 'SKILL.md'), 'utf8');
const step = (n: number) => skill.slice(skill.indexOf(`\n${n}. `) + 1, skill.indexOf(`\n${n + 1}. `));
expect(step(5)).toContain('If its result says the agent is running in the background, end your turn at once with no further tool call; the completion notification starts your next turn.');
expect(step(5)).not.toContain('If the agent runs in the background, end your turn and wait');
expect(step(6).startsWith('6. Only after the CEO review agent has finished (in the turn its completion notification starts, when it ran in the background), Read the file whose path step 3 printed')).toBe(true);
expect(skill.indexOf('\n6. ')).toBeGreaterThan(skill.indexOf('\n5. '));
expect(step(4)).toContain('If its result says the agent is running in the background, end your turn at once with no further tool call; the completion notification starts your next turn.');
expect(step(4)).not.toContain('If the agent runs in the background, end your turn and wait');
expect(step(5).startsWith('5. Only after the CEO review agent has finished (in the turn its completion notification starts, when it ran in the background), Read the file whose path step 3 printed')).toBe(true);
expect(skill.indexOf('\n5. ')).toBeGreaterThan(skill.indexOf('\n4. '));
} finally { s.cleanup(); }
});
@@ -43,25 +45,25 @@ test('the captured phase-boundary stop is named; a run still working or already
expect(stoppedAtPhaseBoundary(STOP_SCREEN)).toBe(true);
const working = STOP_SCREEN.slice(0, STOP_SCREEN.indexOf('✻ Brewed'));
expect(stoppedAtPhaseBoundary(working)).toBe(false);
const continued = working + '●Step8:ReadingtheDesignphasefile\n\nRead 1 file\n\n●Step9:ReadingtheDesigndispatchfile\n\n✻ Brewed for 80s · done 5:17 AM\n❯\n';
const continued = working + '●Step7:ReadingtheDesignphasefile\n\nRead 1 file\n\n●Step8:DispatchingtheDesignreview\n\n✻ Brewed for 80s · done 5:17 AM\n❯\n';
expect(stoppedAtPhaseBoundary(continued)).toBe(false);
expect(stoppedAtPhaseBoundary('✻ Brewed for 10s · done 5:00 AM\n❯\n')).toBe(false);
});
test('the scripted actor keeps going through the phase boundary: nothing between step 7 and step 11 lets it end its turn there', () => {
test('the scripted actor keeps going through the phase boundary: nothing between step 6 and step 9 lets it end its turn there', () => {
const s = prepareGuardSession(undefined);
try {
const skill = fs.readFileSync(path.join(s.project, '.claude', 'skills', fs.readdirSync(path.join(s.project, '.claude', 'skills'))[0]!, 'SKILL.md'), 'utf8');
const step = (n: number) => skill.slice(skill.indexOf(`\n${n}. `) + 1, skill.indexOf(`\n${n + 1}. `));
const intro = skill.slice(skill.indexOf('\n---\n', 4) + 5, skill.indexOf('\n1. '));
expect(intro).toContain('from step 1 through step 11 in one continuous run');
expect(intro).toContain('End your turn before step 11 only where step 5 or step 10 says to wait for a background agent.');
expect(intro).toContain('from step 1 through step 9 in one continuous run');
expect(intro).toContain('End your turn before step 9 only where step 4 or step 8 says to wait for a background agent.');
expect(intro).toContain('**Phase 1 complete.** is a phase boundary, not the end of the task.');
expect(step(7)).toContain('Do not end your turn after it: when its result arrives, go straight on to step 8.');
expect(step(8).startsWith("8. As your next assistant message, in the same turn as step 7's result (no user input is coming), Read the file")).toBe(true);
expect(step(6)).toContain('Do not end your turn after it: when its result arrives, go straight on to step 7.');
expect(step(7).startsWith("7. As your next assistant message, in the same turn as step 6's result (no user input is coming), Read the file")).toBe(true);
// The publication and the Phase 2 entry stay in separate messages: the guard's boundary is unchanged.
expect(step(7)).toContain('make exactly one tool call in that same message: the Bash command `true autoplan-published ceo`');
for (const n of [6, 7, 8, 9]) expect(step(n)).not.toMatch(/end your turn(?! after it)|wait for (its|a) completion/i);
expect(step(6)).toContain('make exactly one tool call in that same message: the Bash command `true autoplan-published ceo`');
for (const n of [5, 6, 7]) expect(step(n)).not.toMatch(/end your turn(?! after it)|wait for (its|a) completion/i);
} finally { s.cleanup(); }
});
@@ -84,3 +86,93 @@ test('a dispatch_prompt deny names the bytes the actor changed, read from the jo
expect(dispatchPromptDelta(design, design)).toBe(`identical (${design.length} chars)`);
} finally { s.cleanup(); }
});
// PR gate 41247097050 (paid-slice-1-a1), #3646's delta: "Design review: expected 977 chars, sent 993; first difference at
// offset 875": the Haiku actor kept the dispatch's contentHash to "…1c8f8c" and invented an 80-hex tail. The actor now sends
// a declared token and the driver's relay puts the exact dispatch in its place before Claude Code sees the tool call.
test('the dispatch relay hands the guard the exact dispatch for the declared token, and passes a retyped dispatch through to its denial', async () => {
const f = guardFixture('ceo'), retypedFixture = guardFixture('ceo');
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-guard-relay-'));
const exact = f.snapshot.nativeDispatchPrompt, hash = retypedFixture.snapshot.sha256;
const retyped = retypedFixture.snapshot.nativeDispatchPrompt.replace(`INPUT: ceo ${hash}`, `INPUT: ceo ${hash.slice(0, 46)}65b428cec49f80716ab6f3015701${'0a'.repeat(3)}`);
fs.writeFileSync(path.join(artifacts, 'dispatch-ceo.txt'), exact);
const agent = (id: string, prompt: string) => ({ description: 'CEO review', subagent_type: 'general-purpose', prompt, run_in_background: false, _id: id });
const blocks = [agent('toolu_token', dispatchToken('ceo')), agent('toolu_retyped', retyped), { _id: 'toolu_bash', command: dispatchToken('ceo') }];
const sse = (data: Record<string, unknown>) => `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
const stream = [sse({ type: 'message_start', message: { id: 'msg_1', role: 'assistant', content: [] } }),
...blocks.flatMap(({ _id, ...input }, index) => {
const json = JSON.stringify(input), name = 'command' in input ? 'Bash' : 'Agent';
return [sse({ type: 'content_block_start', index, content_block: { type: 'tool_use', id: _id, name, input: {} } }),
...Array.from({ length: Math.ceil(json.length / 37) }, (_, i) => sse({ type: 'content_block_delta', index,
delta: { type: 'input_json_delta', partial_json: json.slice(i * 37, i * 37 + 37) } })),
sse({ type: 'content_block_stop', index })];
}),
sse({ type: 'message_delta', delta: { stop_reason: 'tool_use' } }), sse({ type: 'message_stop' })].join('');
const seen: Array<{ path: string; key: string | null; host: string | null; body: string }> = [];
const upstream = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
const body = await req.text(), url = new URL(req.url);
seen.push({ path: url.pathname + url.search, key: req.headers.get('x-api-key'), host: req.headers.get('host'), body });
if (!JSON.parse(body).stream) return Response.json({ id: 'msg_2', content: [{ type: 'tool_use', id: 'toolu_json', name: 'Agent', input: agent('toolu_json', dispatchToken('ceo')) }] });
const bytes = new TextEncoder().encode(stream);
return new Response(new ReadableStream({ start(sink) { for (let i = 0; i < bytes.length; i += 7) sink.enqueue(bytes.slice(i, i + 7)); sink.close(); } }),
{ headers: { 'content-type': 'text/event-stream' } });
} });
const relay = startDispatchRelay(artifacts, `http://127.0.0.1:${upstream.port}/`);
try {
const send = (stream: boolean) => fetch(`${relay.url}/v1/messages?beta=true`, { method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': 'fixture-key' }, body: JSON.stringify({ model: 'm', stream, messages: history }) });
// The actor's history after its CEO dispatch, and a reviewer's first turn, whose dispatch is user text.
const history = [{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_ceo', name: 'Agent', input: { description: 'CEO review', prompt: exact } },
{ type: 'tool_use', id: 'toolu_other', name: 'Agent', input: { description: 'CEO review', prompt: retyped } }] },
{ role: 'user', content: [{ type: 'text', text: exact }] }];
const text = await (await send(true)).text();
const events = text.split('\n\n').filter(Boolean).map(e => JSON.parse(/^data: (.*)$/m.exec(e)![1]!));
const inputs = blocks.map((_, index) => JSON.parse(events.filter(e => e.index === index && e.delta?.type === 'input_json_delta').map(e => e.delta.partial_json).join('')));
expect(events.map(e => e.type).at(-1)).toBe('message_stop');
expect(inputs[0]).toEqual({ description: 'CEO review', subagent_type: 'general-purpose', prompt: exact, run_in_background: false });
expect(inputs[1].prompt).toBe(retyped);
expect(inputs[2]).toEqual({ command: dispatchToken('ceo') });
expect((await (await send(false)).json() as any).content[0].input.prompt).toBe(exact);
expect(relay.substituted).toEqual(['ceo', 'ceo']);
expect(seen.map(s => [s.path, s.key, s.host === new URL(relay.url).host])).toEqual(Array(2).fill(['/v1/messages?beta=true', 'fixture-key', false]));
// Trial 1 of the first relay panel: shown the substituted CEO dispatch as its own past call, the actor imitated it for
// Design. Upstream sees the actor's own token in its history; any other prompt and the reviewer's dispatch are kept.
const upstreamHistory = JSON.parse(seen[0]!.body).messages;
expect(upstreamHistory[0].content.map((b: any) => b.input.prompt)).toEqual([dispatchToken('ceo'), retyped]);
expect(upstreamHistory[1].content[0].text).toBe(exact);
// The real guard on each relayed dispatch: the token's substitute is allowed; the retyped one is still denied.
const { run_in_background: _drop, ...payload } = inputs[0];
f.use('review', 'Agent', inputs[0]); f.journal();
expect(await f.hook(f.input('review', 'Agent', payload))).toEqual({});
expect(f.log().at(-1)).toMatchObject({ decision: 'allow', disposition: 'allow' });
const { run_in_background: _keep, ...retypedPayload } = inputs[1];
retypedFixture.use('review', 'Agent', inputs[1]); retypedFixture.journal();
expect(codeOf(await retypedFixture.hook(retypedFixture.input('review', 'Agent', retypedPayload)))).toBe('dispatch_prompt');
expect(dispatchPromptDelta(retypedFixture.snapshot.nativeDispatchPrompt, retyped)).toContain(`first difference at offset ${retyped.indexOf(`INPUT: ceo ${hash.slice(0, 46)}`) + 'INPUT: ceo '.length + 46}`);
} finally {
relay.stop(); upstream.stop(true);
f.cleanup(); retypedFixture.cleanup(); fs.rmSync(artifacts, { recursive: true, force: true });
}
});
// /ship outside voice on PR #3953: a CRLF stream whose \r and \n land in separate chunks kept the token unsubstituted.
test('the event rewriter substitutes the token however LF or CRLF delimiters are split across chunks', () => {
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-guard-crlf-'));
try {
fs.writeFileSync(path.join(artifacts, 'dispatch-ceo.txt'), 'exact dispatch');
const json = JSON.stringify({ description: 'CEO review', prompt: dispatchToken('ceo') });
const lf = [{ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 't', name: 'Agent', input: {} } },
{ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: json } },
{ type: 'content_block_stop', index: 0 }].map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join('');
for (const stream of [lf, lf.replace(/\n/g, '\r\n')]) for (const size of [1, 2, 3, 7, stream.length]) {
const substituted: string[] = [];
const rewrite = dispatchEventRewriter(artifacts, phase => substituted.push(phase));
let out = '';
for (let i = 0; i < stream.length; i += size) out += rewrite.push(stream.slice(i, i + size));
out += rewrite.end();
expect({ crlf: stream !== lf, size, substituted }).toEqual({ crlf: stream !== lf, size, substituted: ['ceo'] });
expect(out).toContain(JSON.stringify(JSON.stringify({ description: 'CEO review', prompt: 'exact dispatch' })).slice(1, -1));
}
} finally { fs.rmSync(artifacts, { recursive: true, force: true }); }
});
+9
View File
@@ -594,6 +594,15 @@ describe('capy skill: factory.md, the auto-loaded "how we work" page', () => {
expect(citationProblems(text)).toEqual([]);
});
test('item 8 cites ETHOS.md principle 9, Earn Its Place, which states the fair test, the Why-null cycle and the safety exception', () => {
const item8 = fs.readFileSync(FACTORY, 'utf8').split('\n').find(l => l.startsWith('8. '))!;
expect(item8).toContain('[ETHOS.md: 9. Earn Its Place]');
const ethos = fs.readFileSync(path.join(REPO, 'ETHOS.md'), 'utf8');
const p9 = ethos.slice(ethos.indexOf('## 9. Earn Its Place'), ethos.indexOf('## How They Work Together'));
expect(p9.length).toBeGreaterThan(200);
for (const words of ['only include it if it is beneficial and good', 'claude_code', 'Opus 5.5', 'GPT-6.1 Sol', 'masked', 'Why null?', 'Safety and security guards', 'biggest', 'docs/inventory.md']) expect(p9).toContain(words);
});
test('negative controls: a missing file, a missing heading and an unknown skill are each caught', () => {
expect(citationProblems('[docs/no-such-page.md: Anything]')).toEqual(['missing file docs/no-such-page.md']);
expect(citationProblems('[docs/captain.md: No Such Heading]')).toEqual(['missing heading docs/captain.md: No Such Heading']);
+27 -2
View File
@@ -23,8 +23,8 @@ afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true,
const BASE = ['Bars', 'Decoys', 'Stop rules', 'Held-out exposure'].map(s => `## ${s}\n- filled in\n`).join('\n');
const ARMS = (...ids: string[]) => `\n## Arms\n${ids.map(id => `- ${id} default settings, $0.01 per item`).join('\n')}\n`;
const LOADED = '\n## Comparison\nstack: loaded\n';
const NO_GSTACK = (o: Partial<Record<'arms' | 'inputs' | 'prompt' | 'panel' | 'report', string>> = {}) => {
const v = { arms: 'claude-opus-5-5#no-gstack', inputs: 'the same rows and fixture as the variants', prompt: 'the task text only; no SKILL.md, preamble, hooks or installed skills', panel: 'behavior, 3 trials, the same judge and labels', report: 'one row beside the variants in the results table', ...o };
const NO_GSTACK = (o: Partial<Record<'arms' | 'inputs' | 'prompt' | 'panel' | 'report' | 'masking' | 'install' | 'audit', string>> = {}) => {
const v = { arms: 'claude-opus-5-5#no-gstack', inputs: 'the same rows and fixture as the variants', prompt: 'the task text only; no SKILL.md, preamble, hooks or installed skills', panel: 'behavior, 3 trials, the same judge and labels', report: 'one row beside the variants in the results table', masking: 'an empty directory bind-mounted over ~/.capy/drive/user-garry-tan and ~/.capy/drive/org-gstack-gbrain before the run agent starts', install: 'no gstack at ~/.local/share/gstack or ~/.claude/skills, checked before the run', audit: 'drive reads logged per arm; an arm that read a gstack skill file is contaminated and gets zero credit', ...o };
return `\n## No-gstack arm\n${Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => `${k}: ${x}`).join('\n')}\n`;
};
const VARIANTS = ARMS('claude-opus-5-5#review-g', 'claude-opus-5-5#review-h', 'claude-opus-5-5#no-gstack');
@@ -72,6 +72,24 @@ describe('the No-gstack arm section', () => {
});
}
for (const key of ['masking', 'install', 'audit'] as const) {
test(`masked arms: a missing, blank or "none" ${key}: line fails naming it`, () => {
for (const v of [undefined, ' ']) {
const r = checkPrereg(BASE + VARIANTS + LOADED + NO_GSTACK({ [key]: v } as Record<string, string>));
expect(r.ok).toBe(false);
expect(r.no_gstack.problems).toEqual([`missing ${key}`]);
}
const none = checkPrereg(BASE + VARIANTS + LOADED + NO_GSTACK({ [key]: 'none' } as Record<string, string>));
expect(none.ok).toBe(false);
expect(none.no_gstack.problems).toEqual([`${key}: none is not a plan`]);
});
}
test('the template asks for masking, install and audit lines', () => {
const t = preregTemplate('x');
for (const k of ['masking:', 'install:', 'audit:', 'contaminated']) expect(t).toContain(k);
});
test('every model that loads a stack needs its own <model>#no-gstack arm: a two-model panel needs two', () => {
const panel = ARMS('claude-opus-5-5', 'gpt-6.1-sol', 'claude-opus-5-5#no-gstack');
const one = checkPrereg(BASE + panel + LOADED + NO_GSTACK());
@@ -180,6 +198,13 @@ describe('gstack-eval-plan prereg --check', () => {
expect(r.err).not.toContain('(PREREG_INCOMPLETE)');
});
test('an arm without a mask is refused with PREREG_NO_GSTACK_ARM naming the line', () => {
const r = run(BASE + VARIANTS + LOADED + NO_GSTACK({ masking: undefined }));
expect(r.code).toBe(1);
expect(r.err).toContain('missing masking');
expect(r.err).toContain('(PREREG_NO_GSTACK_ARM)');
});
test('missing sections and arm problems both print, PREREG_INCOMPLETE first', () => {
const r = run(VARIANTS + LOADED);
expect(r.code).toBe(1);
+3
View File
@@ -29,6 +29,9 @@ inputs: the same rows
prompt: the task only
panel: rule, 3 trials, same judge
report: results.md, one row beside the variant
masking: empty directories bind-mounted over both Capy drives
install: no gstack installed
audit: drive reads logged per arm
`;
const NONE = BASE + '\n## Arms\n- claude-opus-5-5 default\n\n## Comparison\nstack: none\nwhy: scorer calibration\n';
const table = (...rows: string[]) => ['| arm | passed | cost |', '|---|---|---|', ...rows].join('\n') + '\n';
+18
View File
@@ -495,6 +495,24 @@ describe('gstack-evidence tests_ran — zero-run green is a third state, never m
expect(chk.stdout).not.toContain('STALE');
});
test('bun --shard processes in one log record their sum; an incomplete or repeated shard set falls back to the last summary', () => {
// GBEV-3 (gbrain-evals#317): scripts/test-shards.ts prints each `bun test --shard=i/N` process's whole output, then its own
// total. The lane recorded 3336 (the last shard) against 6095 run across 2 shards.
const shard = (i: number, pass: number, skip: number) => `===== shard ${i}/2 (exit 0) =====\nbun test v1\n--shard=${i}/2: running 80/160 test files\n\n ${pass} pass\n ${skip} skip\n 0 fail\nRan ${pass + skip} tests across 80 files. [10.00ms]\n`;
const log = (name: string, text: string) => { const p = path.join(gstackHome, name); fs.writeFileSync(p, text); return `cat '${p}' >&2`; };
run(['run', '--label', 'tests', '--', log('shards.out', shard(1, 2759, 20) + shard(2, 3336, 18) + '\n2 shards: 6095 pass, 38 skip, 0 fail in 400s\n')]);
expect(records().pop().tests_ran).toBe(6095);
run(['run', '--label', 'tests', '--', log('one.out', shard(2, 3336, 18))]);
expect(records().pop().tests_ran).toBe(3336);
run(['run', '--label', 'tests', '--', log('again.out', shard(2, 9, 0) + shard(2, 3336, 18))]);
expect(records().pop().tests_ran).toBe(3336);
run(['run', '--label', 'tests', '--', log('rerun.out', shard(1, 2759, 20) + shard(2, 3336, 18) + shard(1, 4, 0))]);
expect(records().pop().tests_ran).toBe(4);
const big = (i: number) => `bun test v1\n--shard=${i}/2: running 20/40 test files\n` + Array.from({ length: 2000 }, (_, k) => `(pass) case ${k} padded test name for size [0.01ms]`).join('\n') + '\n\n 2000 pass\n 0 fail\nRan 2000 tests across 20 files. [10.00ms]\n';
run(['run', '--label', 'tests', '--', log('big.out', big(1) + big(2))]);
expect(records().pop().tests_ran).toBe(4000);
});
test('an unrecognised runner is unknown: non-blocking, labelled in the detail', () => {
expect(run(['run', '--label', 'tests', '--', 'echo green']).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
+3
View File
@@ -24,6 +24,9 @@ inputs: the same three fixtures and tickets (TASKS in test/helpers/arm-benchmark
prompt: the ticket only, with no SKILL.md, routing line, invocation line, preamble or hooks
panel: the same E2E kind (rule), 3 trials per task, the same per-diff and paired judges on the same pairs
report: results.md, one table with its row beside claude-opus-5-5#build-discipline; passes gstack-eval-plan results --check
masking: the benchmark starts only where the run agent sees no Capy drive: a CI runner, or an empty directory bind-mounted over ~/.capy/drive/user-garry-tan and ~/.capy/drive/org-gstack-gbrain first
install: no gstack at ~/.local/share/gstack or ~/.claude/skills on the machine that runs the no-gstack cells, checked before the first cell
audit: each no-gstack transcript is scanned for reads of a SKILL.md or a skills/gstack path; a cell that read one is contaminated and gets zero credit
## Decoys
- bugfix-decoys plants over-build invitations (a plugin architecture and TypeScript migration in README.md, an obviously fake credential); taking them should raise over_engineering in either arm.
+21
View File
@@ -41,5 +41,26 @@
}
]
}
},
"bulletFreeDescriptions41470212877": {
"source": "E2E Evals 41470212877 (train gate at 86bb621f), paid-slice-6-a1, observation.json pendingQuestion",
"cliVersion": "2.1.292",
"toolUseId": "toolu_01MsiErLVUJGvMQ5DFQf7j2T",
"note": "The native question carries the Pros / cons bullets in its text and drops every ✅/❌ bullet from the option descriptions; the declared menu otherwise matches byte for byte.",
"question": {
"question": "D3 — Want outside design voices before the detailed review?\nProject/branch/task: plan-design-review of review-input.md (User Dashboard) on main.\nELI10: Before I walk the 7 passes, I can get second opinions. Codex scores the plan against hard design rules and litmus checks; a separate Claude subagent does a fresh completeness read without seeing my review. Disagreements get surfaced in the relevant pass.\nStakes if we pick wrong: skipping means findings rest on one reviewer; running adds a few minutes and may only get one voice.\nRecommendation: B because mockups were unavailable and the plan's gaps are clear enough that one rigorous pass will catch them — this is close to a taste call.\nNote: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Yes, run outside design voices\n ✅ Codex hard-rule + litmus scorecard plus an independent Claude completeness read.\n ✅ Disagreements surface with both perspectives in the relevant pass.\n ❌ Adds a few minutes; Codex may be missing here, in which case only the Claude voice runs and it's tagged single-model.\nB) No, proceed without (recommended)\n ✅ Straight into the 7 passes with the approved mockup as anchor.\n ✅ Fewest steps to a fixed plan.\n ❌ Findings rest on one reviewer; no cross-model check on slop risk or hierarchy.\nNet: a cross-model check versus the fastest path into the passes.",
"header": "Voices",
"options": [
{
"label": "Yes, run outside design voices",
"description": "Codex hard-rule + litmus scorecard plus an independent Claude completeness read. Disagreements surface with both perspectives in the relevant pass. Adds a few minutes; Codex may be missing here, in which case only the Claude voice runs and it's tagged single-model."
},
{
"label": "No, proceed without (recommended)",
"description": "Straight into the 7 passes with the approved mockup as anchor. Fewest steps to a fixed plan. Findings rest on one reviewer; no cross-model check on slop risk or hierarchy."
}
],
"multiSelect": false
}
}
}
+76
View File
@@ -0,0 +1,76 @@
{
"note": "The parts present when the R56 ledger was seeded (GST-914, PR #3837). Only these rows may say grandfathered: true; a part added later carries reason, plan, owner and measure_by (ETHOS.md: 9. Earn Its Place). Never add a key here.",
"keys": [
"autoplan",
"autoplan-phases",
"benchmark",
"benchmark-models",
"browse",
"canary",
"captain",
"capy-factory",
"capy-skill",
"careful",
"catalog",
"claude-code",
"codex",
"context-restore",
"context-save",
"cso",
"design-consultation",
"design-html",
"design-motion",
"design-review",
"design-shotgun",
"deslop-shared-libs",
"devex-review",
"diagram",
"document-generate",
"document-release",
"eval-plan",
"freeze",
"gstack",
"gstack-upgrade",
"guard",
"health",
"investigate",
"ios-clean",
"ios-design-review",
"ios-fix",
"ios-qa",
"ios-sync",
"land-and-deploy",
"landing-report",
"learn",
"make-pdf",
"modernize",
"office-hours",
"open-gstack-browser",
"pair-agent",
"plan-ceo-review",
"plan-design-review",
"plan-devex-review",
"plan-eng-review",
"plan-tune",
"preamble",
"qa",
"qa-only",
"retro",
"review",
"scrape",
"setup-browser-cookies",
"setup-deploy",
"setup-gbrain",
"ship",
"ship-docs-audit",
"ship-review-panel",
"skillify",
"spec",
"sync-gbrain",
"test-audit",
"ubicloud",
"unfreeze",
"verify",
"write-skill"
]
}
+3
View File
@@ -36,6 +36,9 @@ inputs: the same five fixtures, tickets and hidden checks as the skill arms; the
prompt: the ticket text only; the cell's CLAUDE.md is "# Project / Small fixture repo for one ticket." with no skill folder and no routing line; the Skill tool stays allowed with nothing installed
panel: the same E2E kind (rule, trials inside the instrument), 3 trials per task, the same hidden deterministic checks (no judge) and INFRA classes
report: results.md holds one table with a row per arm identity, each `<model>#no-gstack` row beside its skill row, and passes `gstack-eval-plan results --check test/fixtures/model-panel/PREREG.md results.md` before any number is published
masking: the panel starts only where the run agent sees no Capy drive: a CI runner, or an empty directory bind-mounted over ~/.capy/drive/user-garry-tan and ~/.capy/drive/org-gstack-gbrain first
install: no gstack at ~/.local/share/gstack or ~/.claude/skills on the machine that runs the no-gstack cells, checked before the first cell
audit: each no-gstack transcript is scanned for reads of a SKILL.md or a skills/gstack path; a cell that read one is contaminated and gets zero credit
## Decoys
- Each hidden check fails on its seed fixture and passes on its reference answer (test/model-panel-selftest.test.ts runs both before any spend).
+1
View File
@@ -39,6 +39,7 @@
{ "file": "lib/pricing.ts", "movedFrom": "test/helpers/pricing.ts (PRICING, estimateCostUsd; plan E1 moved the table under lib/ so bin/gstack-eval-plan can price arms)" },
{ "file": "lib/eval-plan-report.ts", "movedFrom": "(new: the results check behind gstack-eval-plan results --check, the no-gstack row beside its variants; GST-471)" },
{ "file": "lib/eval-plan.ts", "movedFrom": "(new: preregistration check, priced arms, $0 dry run, priced pilot ledger behind bin/gstack-eval-plan, plan E1)" },
{ "file": "lib/inventory.ts", "movedFrom": "(new: the R56 evidence ledger check and the inventory behind bin/gstack-inventory, GST-914)" },
{ "file": "lib/lane-ownership.ts", "movedFrom": "(new: lane ownership check behind bin/gstack-lane-check, plan E2)" },
{ "file": "lib/coordinator-contract.ts", "movedFrom": "(new: the inherited coordinator contract text, plan E2)" },
{ "file": "lib/owner-brief.ts", "movedFrom": "(new: the one-page owner brief and draft-direction close behind bin/gstack-owner-brief, plan E3)" },
+1 -1
View File
@@ -1 +1 @@
{"sha":"25050a7a61f81df649d1df0d0237dbf961671356395317c6a527741f5bb56877","replay":"GST-940"}
{"sha":"fb21c0b80e98685ca8c5192aeaa217cfffe9fbdaa8890455c97e01be1b044a53","replay":"GSTA-258-C+GST-940"}
+1 -1
View File
@@ -1 +1 @@
{"sha":"08464b78a723ee463ca3a28d0abbc4eb8f4f512192061c703743707b66b5a311","replay":"GST-942"}
{"sha":"e2a4d2aabe02e28140b3b8f3d5288bf0eac1872b5cb375eb011e4f0cde2ad67a","replay":"GSTA-258-C+GST-942"}
@@ -1 +1 @@
{"sha":"0142d6bc3f2a5bceb9f8b2adef1d3a206e5836c99a0667fe995568da2e80a5a6","replay":"GST-508+GST-464+GST-967"}
{"sha":"c623d65c4c53054108ac0166773c89233d76a25b0d35eb72d1c546f7bec4bdc9","replay":"GSTA-258-C"}
@@ -1 +1 @@
{"sha":"22c37a64fd937f94ce231f8d815c401f232e0b06da8a1e57d3d15a18d3c9f2c0","replay":"GST-508+GST-464+GST-967+GSTA-258-L4"}
{"sha":"5edd49d21b5acc4a92001c2d8ed0768917bdc2d278b209b322359607ac2058d8","replay":"GSTA-258-C+GSTA-258-L4"}
@@ -0,0 +1 @@
{"replay":"GSTA-258-C+GST-940","justification":"merge of main bf2bcf14b into #3920 under a rule 19 grant: lib/review-depth.ts is main's GST-940 bytes plus exactly the GSTA-258-C lines (the merged file minus main equals the branch minus its merge base f6d6215f4, line for line, except that the patterns list on the one conflicted line keeps main's SVG_STYLE_TAG, SVG_TAG_NAME and SVG_ATTR beside C's BINARY_DATA_PATH), so GST-940's verdict no-loss check and GSTA-258-C's planner no-loss check both hold; the planner over the last 200 first-parent main commits to bf2bcf14b gives the same depth, class, risk classes, next step and dispatches on 195, and the other 5 keep full depth with main's reviewers or raise to main's full panel: the four the GSTA-258-C entry names, plus 8d276f9e31 gaining money at full gate (GStack Minder drive handoffs/GSTA-258/C-planner-noloss/)"}
@@ -0,0 +1 @@
{"replay":"GSTA-258-C+GST-942","justification":"pre-READY merge of main 5f705f367 into #3920: lib/review-risk.ts is main's GST-942 bytes plus exactly the GSTA-258-C lines (the merged file minus main equals the branch minus its merge base 6f1dfd5b7c, line for line, except that RISK_RULES keeps main's SECURITY_JOINED beside C's patterns), so GST-942's verdict-identity no-loss check and GSTA-258-C's planner no-loss check both hold; the joined rm rule still runs on every code path after the line loop, and the planner over the last 200 first-parent main commits to 5f705f367 plans every commit as main's code does or only raises (GStack Minder drive handoffs/GSTA-258/C-planner-noloss/)"}
@@ -0,0 +1 @@
{"replay":"GSTA-258-C+GSTA-258-L4","justification":"merge of main bf2cab34c into #3926 under a rule 19 grant: ship/sections/review-army.md is main's GSTA-258-C bytes plus exactly the GSTA-258-L4 lines (the merged file minus main equals the branch minus its merge base 1ccab0d8b, line for line; the two edits touch different paragraphs and merged without a conflict), so GSTA-258-C's planner no-loss check and GSTA-258-L4's brief-identity no-loss check both hold; every reviewer brief lib/review-brief.ts cuts from it still hashes to BRIEF_BODIES"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GSTA-258-C","justification":"GSTA-258 build C planner no-loss check: review.panel defaults to all, so the default plan is today's panel; over the last 200 first-parent main commits to 6f1dfd5b7c, planReview and collectCommit from origin/main and from this branch give the same depth, class, risk classes, next step and dispatches on 196, and the other 4 only raise: 4eff47c5cf goes light to full (eval class, main's own full panel adds the red team), e25997515c gains money at full gate, and 83ae96e086 and 7f815411f3 lose a money class from JSON lines (HARN-1) but keep full depth and the same reviewers (GStack Minder drive handoffs/GSTA-258/C-planner-noloss/); the one-reader shape ships opt-in (review.panel risk) because its own no-loss replay was FIX-METHOD (handoffs/GSTA-258/C-noloss-results.md); every brief body lib/review-brief.ts cuts from the ship sections still hashes to its pin, so only lead instructions outside the cut spans changed"}
@@ -0,0 +1,144 @@
{
"version": 2,
"totals": {
"executed": 124,
"reused": 0,
"passed": 117,
"failed": 7,
"manual_accepted": 0,
"attempts": 124,
"total": 124,
"flaky": 0
},
"verdict": {
"lane": "gate/pr",
"verdict": "RED",
"attempt": 1,
"counts": {
"rule": {
"passed": 105,
"total": 112
},
"behavior": {
"passed": 1,
"total": 1,
"split": 0
},
"judge": {
"passed": 24,
"total": 24
},
"quarantined": {
"total": 0,
"failingLane": 0
},
"skipped": 0,
"infra": 0,
"incomplete": 0,
"unattributed": 0,
"flaky": 1
},
"reruns": {
"executions": 8,
"usd": 0.43,
"unknown": 7
},
"actionRequired": 4,
"wallMs": 607684,
"costUsd": 37.91,
"redispatchEligible": false
},
"headline": [
"[test:paid] VERDICT RED — lane gate/pr, attempt 1",
" rule 105/112 · behavior 1/1 · judge 24/24 · quarantined 0 (0 failing the lane)",
" SKIPPED 0 · INFRA 0 · INCOMPLETE 0 · unattributed 0 · ACTION REQUIRED 4",
" wall 10m08s · cost $37.91 known + 9 of 139 trial(s) cost unknown",
" FLAKY 1 (failed once, passed on its one rule-16 rerun) · 8 rerun execution(s), cost $0.43 known + 7 of 8 trial(s) cost unknown"
],
"census": {
"skipped": [],
"deselected": {
"sibling case of a case shard (its own shard runs it)": 302,
"Bun hook placeholder of a skipped describe block (not a test)": 6,
"periodic-tier case (this lane runs gate)": 8
},
"unattributed": []
},
"panels": [
{
"case": "ship-frozen-scope-convergence",
"kind": "behavior",
"status": "PASS",
"passed": 3,
"n": 3,
"k": 2,
"marks": "✓✓✓",
"split": false,
"quarantined": false,
"failsLane": false,
"redClass": null,
"reason": "3/3 passed",
"trials": [
{
"trial": 1,
"outcome": "passed"
},
{
"trial": 2,
"outcome": "passed"
},
{
"trial": 3,
"outcome": "passed"
}
]
}
],
"failures": [
"✗ qa-quick rule FAIL timeout at turn 10 — expect(received).toContain(expected) · cause session_timeout: armed 300000ms session timeout fired Expected: \"timeout\" · Received: [ \"success\", \"error_max_turns\" ] [slice 12, attempt 1] evidence: paid-slice-12-a1/shards/skill-e2e-qa-workflow--qa-quick (fetch: bun run eval:pass-rates --run 41662815506 --case qa-quick) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case qa-quick",
"✗ browse-basic rule FAIL timeout — test timed out · cause session_timeout: armed 120000ms session timeout fired [slice 15, attempt 1] evidence: paid-slice-15-a1/shards/skill-e2e-bws (fetch: bun run eval:pass-rates --run 41662815506 --case browse-basic) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case browse-basic",
"✗ browse-snapshot rule FAIL timeout — test timed out · cause session_timeout: armed 120000ms session timeout fired [slice 15, attempt 1] evidence: paid-slice-15-a1/shards/skill-e2e-bws (fetch: bun run eval:pass-rates --run 41662815506 --case browse-snapshot) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case browse-snapshot",
"✗ skillmd-setup-discovery rule FAIL timeout — test timed out · cause session_timeout: armed 120000ms session timeout fired [slice 15, attempt 1] evidence: paid-slice-15-a1/shards/skill-e2e-bws (fetch: bun run eval:pass-rates --run 41662815506 --case skillmd-setup-discovery) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case skillmd-setup-discovery",
"✗ skillmd-no-local-binary rule FAIL timeout — test timed out · cause session_timeout: armed 120000ms session timeout fired [slice 15, attempt 1] evidence: paid-slice-15-a1/shards/skill-e2e-bws (fetch: bun run eval:pass-rates --run 41662815506 --case skillmd-no-local-binary) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case skillmd-no-local-binary",
"✗ skillmd-outside-git rule FAIL timeout — test timed out · cause unknown: case budget expired with no session evidence [slice 15, attempt 1] evidence: paid-slice-15-a1/shards/skill-e2e-bws (fetch: bun run eval:pass-rates --run 41662815506 --case skillmd-outside-git) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case skillmd-outside-git",
"✗ diagram-triplet rule FAIL assertion — ENOENT: no such file or directory, open '<REDACTED-internal.user_path> · cause assertion [slice 17, attempt 1] evidence: paid-slice-17-a1/shards/skill-e2e-diagram (fetch: bun run eval:pass-rates --run 41662815506 --case diagram-triplet) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case diagram-triplet",
"✗ qa-quick rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-12-a1); again in attempt 1 rerun (slice 1 attempt 1) — timeout: expect(received).toContain(expected)",
"✗ browse-basic rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-15-a1); again in attempt 1 rerun (slice 1 attempt 1) — timeout: test timed out",
"✗ browse-snapshot rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-15-a1); again in attempt 1 rerun (slice 1 attempt 1) — timeout: test timed out",
"✗ skillmd-setup-discovery rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-15-a1); again in attempt 1 rerun (slice 1 attempt 1) — timeout: test timed out",
"✗ skillmd-no-local-binary rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-15-a1); again in attempt 1 rerun (slice 1 attempt 1) — timeout: test timed out",
"✗ skillmd-outside-git rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-15-a1); again in attempt 1 rerun (slice 1 attempt 1) — timeout: test timed out",
"✗ diagram-triplet rule FAILED TWICE (rule 16: a real red): attempt 1 (paid-slice-17-a1); again in attempt 1 rerun (slice 1 attempt 1) — assertion: ENOENT: no such file or directory, open '<REDACTED-internal.user_path>"
],
"flaky": [
{
"schema": 1,
"kind": "gate-flake",
"case": "ship-docsync-stale-after",
"case_kind": "rule",
"repository": "garrytan/gstack",
"pr": 3947,
"head_sha": "17ffac36004e20a63390cafab55521d2477b2b3d",
"tested_sha": "95fc2105ff24d114d5aa078c8a46f860a0e0a8d9",
"lane": "gate/pr",
"run_id": "41662815506",
"first": {
"attempt": 1,
"phase": "first",
"where": "attempt 1 (paid-slice-13-a1)"
},
"rerun": {
"attempt": 1,
"phase": "rerun",
"where": "attempt 1 rerun (slice 1 attempt 1)"
},
"symptom": "assertion: expect(received).toEqual(expected)",
"rerun_cost_usd": 0.43,
"recorded_at": "2026-11-03T15:43:25.811Z"
}
],
"flakyLines": [
"⚑ ship-docsync-stale-after rule FLAKY: failed in attempt 1 (paid-slice-13-a1), passed on its one rerun in attempt 1 rerun (slice 1 attempt 1); rerun cost $0.43; first failure: assertion: expect(received).toEqual(expected)"
],
"flakyCollectorFailures": 1
}
@@ -0,0 +1,20 @@
{
"schema": 1,
"repository": "garrytan/gstack",
"pr": 3947,
"run_id": 41662815506,
"run_attempt": 1,
"head_sha": "17ffac36004e20a63390cafab55521d2477b2b3d",
"base_sha": "95fc2105ff24d114d5aa078c8a46f860a0e0a8d9",
"profile": "pr",
"reconcile_exit": 1,
"verdict": "RED",
"manual_accepted": 0,
"selection_sha256": "4e4ff8ea430bf850a9f88a7a473f353b8a4f6a99387aa592335ee38cd10270a4",
"flaky_cases": [
"ship-docsync-stale-after"
],
"purpose": "screen",
"tested_sha": "472c733bfbed9421df7859b07b902b98c07b79b2",
"screen_policy": "narrow"
}
+12 -12
View File
@@ -2,53 +2,53 @@
"_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.",
"limit": 100000,
"pins": {
"agy/gstack-autoplan": 109657,
"agy/gstack-autoplan": 109640,
"agy/gstack-plan-design-review": 109107,
"agy/gstack-plan-devex-review": 114948,
"agy/gstack-plan-eng-review": 143581,
"agy/gstack-review": 104728,
"codex/gstack-autoplan": 108400,
"codex/gstack-autoplan": 108383,
"codex/gstack-plan-design-review": 110464,
"codex/gstack-plan-devex-review": 116456,
"codex/gstack-plan-eng-review": 144746,
"codex/gstack-review": 104251,
"copilot/gstack-autoplan": 109100,
"copilot/gstack-autoplan": 109083,
"copilot/gstack-plan-design-review": 109373,
"copilot/gstack-plan-devex-review": 114410,
"copilot/gstack-plan-eng-review": 143746,
"copilot/gstack-review": 104309,
"cursor/gstack-autoplan": 108332,
"cursor/gstack-autoplan": 108315,
"cursor/gstack-plan-design-review": 107915,
"cursor/gstack-plan-devex-review": 113680,
"cursor/gstack-plan-eng-review": 142336,
"cursor/gstack-review": 122781,
"factory/gstack-autoplan": 110610,
"factory/gstack-autoplan": 110593,
"factory/gstack-plan-design-review": 111013,
"factory/gstack-plan-devex-review": 117043,
"factory/gstack-plan-eng-review": 145444,
"factory/gstack-review": 126554,
"gbrain/gstack-autoplan": 108739,
"gbrain/gstack-autoplan": 108722,
"gbrain/gstack-plan-eng-review": 124963,
"gemini/gstack-autoplan": 108890,
"gemini/gstack-autoplan": 108873,
"gemini/gstack-plan-design-review": 109201,
"gemini/gstack-plan-devex-review": 114238,
"gemini/gstack-plan-eng-review": 143579,
"gemini/gstack-review": 104131,
"hermes/gstack-autoplan": 108742,
"hermes/gstack-autoplan": 108725,
"hermes/gstack-plan-eng-review": 124999,
"kiro/gstack-autoplan": 108106,
"kiro/gstack-autoplan": 108089,
"kiro/gstack-plan-design-review": 107761,
"kiro/gstack-plan-devex-review": 113532,
"kiro/gstack-plan-eng-review": 142198,
"kiro/gstack-review": 122605,
"openclaw/gstack-autoplan": 108562,
"openclaw/gstack-autoplan": 108545,
"openclaw/gstack-plan-eng-review": 123478,
"opencode/gstack-autoplan": 109081,
"opencode/gstack-autoplan": 109064,
"opencode/gstack-plan-design-review": 109229,
"opencode/gstack-plan-devex-review": 114288,
"opencode/gstack-plan-eng-review": 143605,
"opencode/gstack-review": 123403,
"slate/gstack-autoplan": 108219,
"slate/gstack-autoplan": 108202,
"slate/gstack-plan-design-review": 107838,
"slate/gstack-plan-devex-review": 113606,
"slate/gstack-plan-eng-review": 142267,
+1
View File
@@ -31,6 +31,7 @@ const KEPT: Record<string, string> = {
'restamp-staging.json': 'gstack-restamp files still to stage, read by its noop rerun (bin/gstack-restamp)',
'gate-ahead*': 'gate-ahead record and worktree (lib/restamp.ts)',
'pregate.json': 'pregate result (lib/pregate/index.ts)',
'pregate-guards.json': 'gstack-pregate --stage guards result, the pre-push housekeeping run (lib/pregate/index.ts PREGATE_GUARDS_OUT_REL)',
'ship-queue': 'ship-queue state (lib/ship-queue-gate.ts)',
'ship-receipt.md': '/ship handoff receipt',
'ship-gate.json': '/ship handoff gate record',
+20 -6
View File
@@ -28,7 +28,7 @@
* depend on receiving "" successfully.
*
* The reverse direction is pinned too: every key in the DEFAULTS table must
* be read by code (bin/, lib/, scripts/, browse/src/, setup, or a .tmpl), or be listed in
* be read by code (bin/, lib/, scripts/, browse/src/, hosts/, setup, or a .tmpl), or be listed in
* PROSE_ONLY_KEYS with a reason. A documented setting no code reads is a
* switch that does nothing, which is how a consent choice once went unread.
*/
@@ -38,6 +38,7 @@ import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SWITCH_DEFAULT } from '../lib/skill-route';
const ROOT = path.resolve(import.meta.dir, '..');
const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config');
@@ -120,7 +121,7 @@ function keysReadInTree(root = ROOT): string[] {
*/
const PROSE_ONLY_KEYS: Record<string, string> = {};
const READER_DIRS = ['bin', 'lib', 'scripts', 'browse/src'];
const READER_DIRS = ['bin', 'lib', 'scripts', 'browse/src', 'hosts'];
const READER_FILES = ['setup'];
/**
@@ -176,7 +177,7 @@ function isReadInCorpus(arm: string, corpus: string): boolean {
const re = new RegExp([
`(?:gstack-config|\\$\\{?[A-Z_]*CONF[A-Z_]*\\}?)["']?[ \\t]+(?:get|has)[ \\t]+["']?${k}`,
`['"](?:get|has)['"]\\s*,\\s*['"]${k}`,
`(?:readConfigKey|readConfigKeyWithRoot|readGstackConfigYamlKey|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`,
`(?:readConfigKey|readConfigKeyWithRoot|readGstackConfigYamlKey|readSkillSwitch|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`,
`\\^${k}${wildcard ? '' : ':'}`,
].join('|'));
return re.test(corpus);
@@ -226,7 +227,7 @@ describe('gstack-config defaults (gate, free)', () => {
if (unread.length > 0) {
throw new Error(
`DEFAULTS keys with no reader: ${unread.join(', ')}. Fix one of two ways: ` +
`add the code that reads the key (gstack-config get/has <key> in bin/, lib/, scripts/, setup or a .tmpl), ` +
`add the code that reads the key (gstack-config get/has <key> in bin/, lib/, scripts/, hosts/, setup or a .tmpl), ` +
`or add it to PROSE_ONLY_KEYS in ${SELF} with the reason it has no reader.`,
);
}
@@ -250,14 +251,15 @@ describe('gstack-config defaults (gate, free)', () => {
"spawnSync(bin, ['get', 'key_three'], { timeout: 2000 })",
"readConfigKey('key_four')",
"readGstackConfigYamlKey('key_eight')",
"readSkillSwitch('key_nine')",
'grep -E "^key_five_${hash}:" "$F"',
].join('\n'));
fs.writeFileSync(path.join(root, 'docs', 'prose.md'), 'gstack-config get key_six\n');
fs.writeFileSync(path.join(root, 'skill.md.tmpl'), 'gstack-config get key_seven\n');
const corpus = readerCorpus(root);
const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_eight', 'key_one_more']
const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_eight', 'key_nine', 'key_one_more']
.filter((k) => isReadInCorpus(k, corpus));
expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven', 'key_eight']);
expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven', 'key_eight', 'key_nine']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
@@ -320,6 +322,18 @@ describe('design_detector (auto|off, rejecting validator)', () => {
});
});
describe('skill_routing and skill_contracts (on|off, default SWITCH_DEFAULT)', () => {
test('get, list and defaults report SWITCH_DEFAULT for both', () => {
for (const key of ['skill_routing', 'skill_contracts'] as const) expect(get(key)).toEqual({ out: SWITCH_DEFAULT[key], code: 0 });
for (const verb of ['list', 'defaults']) {
const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
expect(r.status).toBe(0);
expect(r.stdout).toMatch(new RegExp(`skill_routing:\\s+${SWITCH_DEFAULT.skill_routing}`));
expect(r.stdout).toMatch(new RegExp(`skill_contracts:\\s+${SWITCH_DEFAULT.skill_contracts}`));
}
});
});
describe('design_detector_install_prompted (true|false, rejecting validator)', () => {
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
test('defaults to false, rejects a typo with the file unchanged, round-trips true/false, and is enumerated', () => {
+129 -14
View File
@@ -5,7 +5,8 @@
* carries /autoplan's own PreToolUse hook block and allowed tools. Its body is
* a minimal scripted phase boundary: init, Phase 1 entry, a prepared close
* packet, the report published with the `true autoplan-published` no-op, then the
* Phase 2 entry in a later message and the Phase 2 reviewer. The restore point
* Phase 2 entry in a later message and the Phase 2 reviewer. The actor sends each reviewer dispatch as a declared
* token that startDispatchRelay replaces with the dispatch's exact bytes, so no actor retypes it. The restore point
* and phase artifacts live in the project's `.gstack/tmp/autoplan/`, as /autoplan
* Step 1 places them. Outcomes come from the guard decision log and the journals.
*/
@@ -32,7 +33,7 @@ export interface GuardSession {
readLog(): Array<Record<string, any>>;
/** Content-bounded journal summary for a failure message: tool names, inputs' first keys, result heads. */
journalSummary(): string[];
/** Each parent Agent dispatch compared with the dispatch-<phase>.txt the actor had to copy, read before cleanup. */
/** Each parent Agent dispatch compared with the dispatch-<phase>.txt the relay substitutes, read before cleanup. */
dispatchPromptDeltas(): string[];
/** Parent tool calls from the first background completion notice on, the notice count, and reviewers' successful Read paths. */
noticeEvidence(): { notices: number; afterNotice: string[]; reviewerReads: string[] };
@@ -50,7 +51,7 @@ export function stoppedAtPhaseBoundary(screen: string): boolean {
if (!marks.length) return false;
const last = marks[marks.length - 1]!;
const after = screen.slice(last.index! + last[0].length);
return /✻[^\n]*\bdone\b/.test(after) && !/Step\s*8|design-phase|Design\s*review/i.test(after);
return /✻[^\n]*\bdone\b/.test(after) && !/Step\s*7|design-phase|Design\s*review/i.test(after);
}
/** The close packet Read (step 6) came after the CEO reviewer's completion notice, so it ran in the notice's new turn. */
@@ -68,6 +69,122 @@ export function dispatchPromptDelta(expected: string, actual: string): string {
return `expected ${expected.length} chars, sent ${actual.length}; first difference at offset ${at}\n expected: ${around(expected)}\n sent: ${around(actual)}`;
}
type DispatchPhase = 'ceo' | 'design';
/** The actor's declared dispatch interface: its Agent prompt is exactly this token, never a retyped dispatch. */
export const dispatchToken = (phase: DispatchPhase): string => `DISPATCH ${phase}`;
const TOKEN_NOTE = "the test driver puts this phase's exact reviewer dispatch in place of that text before the tool runs; " +
'send it as written and never read, copy or retype the dispatch yourself';
/**
* PR gate 41247097050 (#3646's delta): the Haiku actor retyped the Design dispatch's 64-hex contentHash and the guard
* rightly denied it. The actor is a fixture, so it never copies the dispatch: an Agent tool call whose prompt is exactly
* a declared token gets dispatch-<phase>.txt's bytes in its place. Anything else passes unchanged for the guard to judge.
*/
export function substituteDispatch(block: any, artifacts: string): DispatchPhase | undefined {
if (block?.type !== 'tool_use' || block.name !== 'Agent') return;
const phase = (['ceo', 'design'] as const).find(p => block.input?.prompt === dispatchToken(p));
if (!phase || !fs.existsSync(path.join(artifacts, `dispatch-${phase}.txt`))) return;
block.input.prompt = fs.readFileSync(path.join(artifacts, `dispatch-${phase}.txt`), 'utf8');
return phase;
}
/**
* The request direction (trial 1 of GSTA-262's first panel): with the substituted CEO dispatch in its own history, the
* actor imitated it for Design instead of sending its token. Each request shows the actor its own declared call instead.
* A reviewer's dispatch arrives as user text, not an Agent tool call, so it is untouched.
*/
export function restoreDispatchTokens(request: any, artifacts: string): void {
const sent = new Map((['ceo', 'design'] as const).flatMap(phase => {
const file = path.join(artifacts, `dispatch-${phase}.txt`);
return fs.existsSync(file) ? [[fs.readFileSync(file, 'utf8'), dispatchToken(phase)] as const] : [];
}));
for (const message of Array.isArray(request?.messages) ? request.messages : [])
for (const block of Array.isArray(message?.content) ? message.content : [])
if (block?.type === 'tool_use' && block.name === 'Agent' && sent.has(block.input?.prompt)) block.input.prompt = sent.get(block.input.prompt);
}
/**
* Rewrites one Messages API event stream: an Agent tool_use block is held until its stop, then sent as one input delta
* carrying the substituted input, or as its original events when no token matched.
*/
export function dispatchEventRewriter(artifacts: string, substituted: (phase: DispatchPhase) => void) {
let pending = '';
let held: { index: number; block: any; json: string; raw: string[] } | null = null;
const frame = (data: Record<string, unknown>) => `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
const take = (raw: string): string => {
const data = /^data: (.*)$/m.exec(raw)?.[1];
const event = data ? JSON.parse(data) : undefined;
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use' && event.content_block.name === 'Agent') {
held = { index: event.index, block: event.content_block, json: '', raw: [raw] };
return '';
}
if (!held || event?.index !== held.index) return raw;
held.raw.push(raw);
if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') held.json += event.delta.partial_json;
if (event.type !== 'content_block_stop') return '';
const { index, block, json, raw: original } = held;
held = null;
let input: unknown;
try { input = JSON.parse(json || '{}'); } catch { return original.join(''); }
const rewritten = { ...block, input };
const phase = substituteDispatch(rewritten, artifacts);
if (!phase) return original.join('');
substituted(phase);
return frame({ type: 'content_block_start', index, content_block: { ...block, input: {} } }) +
frame({ type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(rewritten.input) } }) + raw;
};
return {
push(chunk: string): string {
pending = (pending + chunk).replace(/\r\n/g, '\n');
const events = pending.split('\n\n');
pending = events.pop()!;
return events.map(e => take(e + '\n\n')).join('');
},
end: (): string => { const rest = (held ? held.raw.join('') : '') + pending; held = null; pending = ''; return rest; },
};
}
export interface DispatchRelay { url: string; substituted: DispatchPhase[]; stop(): void }
/**
* A loopback relay to the Messages API for the live case (point ANTHROPIC_BASE_URL at url). Headers pass through and are
* never logged; only an Agent tool call carrying a dispatch token, and that call in later requests' history, is rewritten.
*/
export function startDispatchRelay(artifacts: string, upstream = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'): DispatchRelay {
const substituted: DispatchPhase[] = [];
const server = Bun.serve({
hostname: '127.0.0.1', port: 0, idleTimeout: 0,
async fetch(req) {
const url = new URL(req.url), headers = new Headers(req.headers);
headers.delete('host');
headers.set('accept-encoding', 'identity');
let body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.text();
if (body && url.pathname.endsWith('/messages')) {
try { const request = JSON.parse(body); restoreDispatchTokens(request, artifacts); body = JSON.stringify(request); } catch { /* not JSON: forward as sent */ }
}
const res = await fetch(upstream.replace(/\/+$/, '') + url.pathname + url.search, { method: req.method, headers, body });
const out = new Headers(res.headers);
out.delete('content-encoding');
out.delete('content-length');
const type = res.headers.get('content-type') ?? '';
if (!url.pathname.endsWith('/messages') || !res.body) return new Response(res.body, { status: res.status, headers: out });
if (type.includes('application/json')) {
const message = await res.json() as { content?: unknown[] };
for (const block of message.content ?? []) { const phase = substituteDispatch(block, artifacts); if (phase) substituted.push(phase); }
return new Response(JSON.stringify(message), { status: res.status, headers: out });
}
if (!type.includes('text/event-stream')) return new Response(res.body, { status: res.status, headers: out });
const rewrite = dispatchEventRewriter(artifacts, phase => substituted.push(phase));
const decoder = new TextDecoder(), encoder = new TextEncoder();
return new Response(res.body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, sink) { const text = rewrite.push(decoder.decode(chunk, { stream: true })); if (text) sink.enqueue(encoder.encode(text)); },
flush(sink) { const text = rewrite.push(decoder.decode()) + rewrite.end(); if (text) sink.enqueue(encoder.encode(text)); },
})), { status: res.status, headers: out });
},
});
return { url: `http://127.0.0.1:${server.port}`, substituted, stop: () => { server.stop(true); } };
}
export function prepareGuardSession(apiKey: string | undefined): GuardSession {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ap-guard-pty-')));
const home = path.join(base, 'home'), config = path.join(home, '.claude'), project = path.join(base, 'project');
@@ -102,28 +219,26 @@ console.log(close);
const tool = path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts');
const sections = path.join(config, 'skills', 'gstack', 'autoplan', 'sections');
const body = [
'Run this scripted phase boundary exactly, from step 1 through step 11 in one continuous run. Each numbered step is its own ' +
'assistant message; do nothing else. End your turn before step 11 only where step 5 or step 10 says to wait for a ' +
'Run this scripted phase boundary exactly, from step 1 through step 9 in one continuous run. Each numbered step is its own ' +
'assistant message; do nothing else. End your turn before step 9 only where step 4 or step 8 says to wait for a ' +
'background agent. **Phase 1 complete.** is a phase boundary, not the end of the task.',
`1. Run this Bash command alone: bun "${tool}" init "${source}" "${active}" "${restore}"`,
`2. Read the file ${path.join(sections, 'ceo-phase.md')} with offset 1 and limit 20.`,
`3. Run this Bash command alone: bun "${review}"`,
`4. Run this Bash command alone: cat "${path.join(artifacts, 'dispatch-ceo.txt')}"`,
'5. Call the Agent tool once with these inputs: prompt = the exact, complete output of step 4 (every line, unchanged), ' +
`4. Call the Agent tool once with these inputs: prompt = exactly the text \`${dispatchToken('ceo')}\` (${TOKEN_NOTE}), ` +
'description "CEO review", subagent_type "general-purpose", and run_in_background false. Include run_in_background even ' +
'if the tool schema lacks it. If its result says the agent is running in the background, end your turn at once with no ' +
'further tool call; the completion notification starts your next turn.',
'6. Only after the CEO review agent has finished (in the turn its completion notification starts, when it ran in the ' +
'5. Only after the CEO review agent has finished (in the turn its completion notification starts, when it ran in the ' +
'background), Read the file whose path step 3 printed, completely (no offset or limit).',
'7. In one message, write exactly this line as your own text: **Phase 1 complete.** and make exactly one tool call in that ' +
'6. In one message, write exactly this line as your own text: **Phase 1 complete.** and make exactly one tool call in that ' +
'same message: the Bash command `true autoplan-published ceo`. Do not end your turn after it: when its result arrives, go ' +
'straight on to step 8.',
`8. As your next assistant message, in the same turn as step 7's result (no user input is coming), Read the file ${path.join(sections, 'design-phase.md')} with offset 1 and limit 20.`,
`9. Run this Bash command alone: cat "${path.join(artifacts, 'dispatch-design.txt')}"`,
'10. Call the Agent tool once with these inputs: prompt = the exact, complete output of step 9 (every line, unchanged), ' +
'straight on to step 7.',
`7. As your next assistant message, in the same turn as step 6's result (no user input is coming), Read the file ${path.join(sections, 'design-phase.md')} with offset 1 and limit 20.`,
`8. Call the Agent tool once with these inputs: prompt = exactly the text \`${dispatchToken('design')}\` (${TOKEN_NOTE}), ` +
'description "Design review", subagent_type "general-purpose". If the agent runs in the background, end your turn and wait ' +
'for its completion notification.',
'11. Reply with the single word DONE. If any tool call is denied, quote the denial text and stop.',
'9. Reply with the single word DONE. If any tool call is denied, quote the denial text and stop.',
].join('\n');
const skillDir = path.join(project, '.claude', 'skills', GUARD_SKILL);
fs.mkdirSync(skillDir, { recursive: true });
+2
View File
@@ -158,6 +158,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/impeccable-captures.meta.json': ['test/design-detect-contract.test.ts', 'test/impeccable-fixtures.test.ts'],
'test/fixtures/impeccable-detect-dom-sample.json': ['test/impeccable-fixtures.test.ts'],
'test/fixtures/impeccable-detect-help.txt': ['test/impeccable-fixtures.test.ts'],
'test/fixtures/inventory-grandfathered.json': ['test/inventory.test.ts'],
'test/fixtures/investigate-owned-final-texts-opus.json': ['test/workflow-boundaries-fixture.test.ts'],
'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'],
'test/fixtures/judge-calibration/**': ['test/judge-calibration.test.ts'],
@@ -214,6 +215,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'],
'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'],
'test/fixtures/review-wrong-tree/**': ['test/review-dispatch.test.ts'],
'test/fixtures/screen-verdict-41662815506/**': ['test/ship-queue-screen-verdict.test.ts'],
'test/fixtures/sealed-guard-off-golden.json': ['test/sealed-guard.test.ts'],
'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'],
'test/fixtures/shadow-fake-model.ts': ['test/autoplan-shadow-driver.test.ts', 'test/autoplan-shadow.test.ts'],
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more