v1.94.30.0 stamp: merge main ade35bc813 into #3920 (land)

This commit is contained in:
garrytan committed 2026-11-03 18:16:02 +00:00
commit 0c6820051b
55 files changed
+2378 -180

No files matched your search

+9
View File
@@ -31,5 +31,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"] }
}
+76
View File
@@ -1,5 +1,81 @@
# Changelog
## [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.30.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.30.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
+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; }
+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).`;
+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]
-15
View File
@@ -1,15 +0,0 @@
**/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.
+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
+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). |
+6 -4
View File
@@ -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).
+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.
+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) {
+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',
+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}`);
+1 -1
View File
@@ -248,7 +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.95.0.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',
+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
@@ -153,8 +155,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}`);
@@ -219,6 +221,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 };
@@ -227,7 +231,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 };
+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");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.94.24",
"version": "1.94.30",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+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.
+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.
+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)" },
+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',
+1
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'],
+220
View File
@@ -0,0 +1,220 @@
/**
* R56 (ETHOS.md: 9. Earn Its Place): every part of gstack has a row in
* docs/inventory/evidence.json, validated by its status's own fields, and
* bin/gstack-inventory ranks the parts by load per run into docs/inventory.md.
*/
import { afterAll, describe, expect, test } from 'bun:test';
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 { DISPATCHES, LEDGER_PATH, ROOT_KEY, SHARED_KEYS, SHARED_PARTS, installReason, inventoryKeys, keyOf, validateEntry, validateLedger } from '../lib/inventory';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-inventory');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-inventory-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const codes = (key: string, entry: unknown) => validateEntry(key, entry).map(p => p.code);
const run = (args: string[]) => spawnSync(BIN, args, { cwd: ROOT, encoding: 'utf8', timeout: 120_000 });
const VERDICT = { sources: ['gstack-evals run r9 results.json'], models: ['claude-opus-5-5', 'gpt-6.1-sol'], prompt: 'preset', scope: 'the full procedure, told', masked: true };
const NEW_UNTESTED = { status: 'untested', reason: 'catches missed migrations', plan: 'test/fixtures/skill-evals/demo/tasks.json', owner: 'GSTA-168', measure_by: '2026-12-01' };
describe('the key list', () => {
test('keys are the skill directories discoverTemplates finds, the root skill as gstack, then the fixed shared keys', () => {
const keys = inventoryKeys(ROOT);
const skills = discoverTemplates(ROOT).map(t => path.dirname(t.tmpl)).map(d => (d === '.' ? ROOT_KEY : d));
expect(keys).toEqual([...skills.sort(), ...SHARED_KEYS]);
expect(keys).toContain('gstack');
expect(keys).toContain('gstack-upgrade');
expect(keys).not.toContain('connect-chrome');
expect(new Set(keys).size).toBe(keys.length);
});
test('installed names map to keys: gstack-<name> to <name>, gstack-upgrade stays literal', () => {
expect(keyOf('gstack-ship')).toBe('ship');
expect(keyOf('ship')).toBe('ship');
expect(keyOf('gstack-upgrade')).toBe('gstack-upgrade');
expect(keyOf('gstack')).toBe('gstack');
});
});
describe('the ledger covers every key and validates', () => {
const ledger = JSON.parse(fs.readFileSync(path.join(ROOT, LEDGER_PATH), 'utf8'));
test('no missing and no extra keys, and every row passes its status schema', () => {
const keys = inventoryKeys(ROOT);
expect(Object.keys(ledger.parts).filter(k => !keys.includes(k))).toEqual([]);
expect(keys.filter(k => !(k in ledger.parts))).toEqual([]);
expect(validateLedger(ledger, keys)).toEqual([]);
});
test('the initial backlog is grandfathered, nothing else is, and the guards are safety rows', () => {
const backlog: string[] = JSON.parse(fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'inventory-grandfathered.json'), 'utf8')).keys;
for (const key of backlog) expect([key, ledger.parts[key]?.grandfathered]).toEqual([key, true]);
for (const [key, row] of Object.entries<any>(ledger.parts)) if (row.grandfathered) expect(backlog).toContain(key);
for (const key of ['careful', 'freeze', 'guard', 'unfreeze', 'cso']) expect(ledger.parts[key].status).toBe('safety');
for (const key of ['capy-skill', 'capy-factory']) expect(ledger.parts[key]).toMatchObject({ status: 'untested', plan: 'none' });
});
test('a missing and an extra key are each named', () => {
const keys = inventoryKeys(ROOT);
const { ship, ...rest } = ledger.parts;
const problems = validateLedger({ ...ledger, parts: { ...rest, 'no-such-part': ship } }, keys);
expect(problems.map(p => `${p.key} ${p.code}`)).toEqual(['ship LEDGER_KEY_MISSING', 'no-such-part LEDGER_KEY_EXTRA']);
});
});
describe('the per-status schema', () => {
test('passing rows: a grandfathered untested part with plan none, a new part with a dated plan, a fair verdict, a safety guard', () => {
expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none' })).toEqual([]);
expect(codes('a', NEW_UNTESTED)).toEqual([]);
expect(codes('a', { ...NEW_UNTESTED, plan: 'docs/evals/demo/PREREG.md' })).toEqual([]);
expect(codes('a', { status: 'helps', ...VERDICT })).toEqual([]);
expect(codes('a', { status: 'helps', ...VERDICT, models: ['claude-opus-5-5', 'particle-1'] })).toEqual([]);
expect(codes('a', { status: 'no-effect', ...VERDICT, r48: { verdict: 'CUT-CANDIDATE', ref: 'why-null.md' } })).toEqual([]);
expect(codes('a', { status: 'safety', reason: 'blocks rm -rf' })).toEqual([]);
expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ ref: 'run 7', why: ['stub', 'unmasked'] }] })).toEqual([]);
});
test('a new untested part needs reason, a concrete plan, owner and measure_by; plan none is refused', () => {
const { measure_by, ...noDate } = NEW_UNTESTED;
expect(codes('a', noDate)).toEqual(['LEDGER_MEASURE_BY_MISSING']);
expect(codes('a', { ...NEW_UNTESTED, measure_by: 'soon' })).toEqual(['LEDGER_MEASURE_BY_MISSING']);
const { owner, ...noOwner } = NEW_UNTESTED;
expect(codes('a', noOwner)).toEqual(['LEDGER_OWNER_MISSING']);
const { reason, ...noReason } = NEW_UNTESTED;
expect(codes('a', noReason)).toEqual(['LEDGER_REASON_MISSING']);
expect(codes('a', { ...NEW_UNTESTED, plan: 'none' })).toEqual(['LEDGER_PLAN_NONE_NEW']);
expect(codes('a', { ...NEW_UNTESTED, plan: 'we will test it later' })).toEqual(['LEDGER_PLAN_NOT_CONCRETE']);
expect(codes('a', { ...NEW_UNTESTED, plan: 'GEVA-2 registry row R56-11 (proposed, not yet preregistered)' })).toEqual(['LEDGER_PLAN_NOT_CONCRETE']);
expect(codes('a', { ...NEW_UNTESTED, plan: 'prereg later' })).toEqual(['LEDGER_PLAN_NOT_CONCRETE']);
expect(codes('a', { ...NEW_UNTESTED, plan: 'wiki/plans/2026-11-01-demo-prereg.md' })).toEqual([]);
expect(codes('a', { status: 'untested', grandfathered: true })).toEqual(['LEDGER_PLAN_MISSING']);
});
test('helps, no-effect and hurts need sources, frontier models, the preset prompt, a scope and a masked no-gstack arm', () => {
const { sources, ...noSources } = VERDICT;
expect(codes('a', { status: 'helps', ...noSources })).toEqual(['LEDGER_SOURCES_MISSING']);
expect(codes('a', { status: 'helps', ...VERDICT, sources: [] })).toEqual(['LEDGER_SOURCES_MISSING']);
expect(codes('a', { status: 'helps', ...VERDICT, models: ['claude-haiku-5-5'] })).toEqual(['LEDGER_MODELS_NOT_FRONTIER']);
expect(codes('a', { status: 'helps', ...VERDICT, models: ['claude-opus-5-5', 'claude-sonnet-5-5'] })).toEqual(['LEDGER_MODELS_NOT_FRONTIER']);
expect(codes('a', { status: 'helps', ...VERDICT, models: ['claude-opus-5-5', 'particle-1', 'deepseek-v4.1-flash'] })).toEqual(['LEDGER_MODELS_NOT_FRONTIER']);
expect(codes('a', { status: 'helps', ...VERDICT, prompt: 'sdk-stub' })).toEqual(['LEDGER_PROMPT_NOT_PRESET']);
expect(codes('a', { status: 'hurts', ...VERDICT, scope: '', r48: { verdict: 'KEEP', ref: 'x' } })).toEqual(['LEDGER_SCOPE_MISSING']);
expect(codes('a', { status: 'helps', ...VERDICT, masked: false })).toEqual(['LEDGER_UNMASKED']);
});
test('no-effect and hurts without an R48 verdict and its reference are refused', () => {
expect(codes('a', { status: 'no-effect', ...VERDICT })).toEqual(['LEDGER_R48_MISSING']);
expect(codes('a', { status: 'hurts', ...VERDICT, r48: { verdict: 'DROP', ref: 'x' } })).toEqual(['LEDGER_R48_MISSING']);
expect(codes('a', { status: 'hurts', ...VERDICT, r48: { verdict: 'KEEP' } })).toEqual(['LEDGER_R48_MISSING']);
});
test('safety needs a reason; unknown statuses, unknown fields and bad prior entries are refused', () => {
expect(codes('a', { status: 'safety' })).toEqual(['LEDGER_REASON_MISSING']);
expect(codes('a', { status: 'exempt', reason: 'x' })).toEqual(['LEDGER_STATUS_INVALID']);
expect(codes('a', { status: 'safety', reason: 'x', source: 'typo' })).toEqual(['LEDGER_FIELD_UNKNOWN']);
expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ ref: 'run 7', why: ['vibes'] }] })).toEqual(['LEDGER_PRIOR_INVALID']);
expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ why: ['stub'] }] })).toEqual(['LEDGER_PRIOR_INVALID']);
expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ ref: 'run 7', why: ['toString'] }] })).toEqual(['LEDGER_PRIOR_INVALID']);
});
test('a present r48 is checked on every status, and aliases is a list of names', () => {
expect(codes('a', { status: 'helps', ...VERDICT, r48: { verdict: 'DROP', ref: 'x' } })).toEqual(['LEDGER_R48_MISSING']);
expect(codes('a', { status: 'helps', ...VERDICT, r48: {} })).toEqual(['LEDGER_R48_MISSING']);
expect(codes('a', { status: 'safety', reason: 'x', aliases: 'other-name' })).toEqual(['LEDGER_FIELD_INVALID']);
expect(codes('a', { status: 'safety', reason: 'x', aliases: [] })).toEqual(['LEDGER_FIELD_INVALID']);
expect(codes('a', { status: 'safety', reason: 'x', aliases: ['other-name'] })).toEqual([]);
});
});
/** A small gstack-shaped tree: a root skill, two skills, the shared-part files and the cited dispatch sources. */
function fixtureTree(dir: string, parts: (keys: string[]) => Record<string, unknown>): string {
const write = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), text); };
const skill = (rel: string, name: string, tier: number, body: string) => {
const fm = `---\nname: ${name}\npreamble-tier: ${tier}\ndescription: |\n Does ${name} things.\n---\n`;
write(path.join(rel, 'SKILL.md.tmpl'), fm + body);
write(path.join(rel, 'SKILL.md'), fm + '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n' + body);
};
skill('', 'gstack', 1, 'router\n');
skill('alpha', 'alpha', 2, 'a'.repeat(4000) + '\n');
skill('beta', 'beta', 2, 'b'.repeat(1000) + '\n');
write('alpha/sections/preamble.md', 'p'.repeat(800));
write('beta/sections/preamble.md', 'p'.repeat(800));
for (const part of SHARED_PARTS) for (const f of part.files) write(f, 'x'.repeat(500) + '\n');
for (const d of DISPATCHES) if (!fs.existsSync(path.join(dir, d.file))) write(d.file, 'filler\n');
for (const d of DISPATCHES) fs.appendFileSync(path.join(dir, d.file), `${d.quote}\n`);
const keys = inventoryKeys(dir);
write(LEDGER_PATH, JSON.stringify({ schema_version: 1, parts: parts(keys), unmapped: [{ row: 'gstack as a whole', why: 'not a single text part' }] }, null, 2));
return dir;
}
const grandfathered = (keys: string[]) => Object.fromEntries(keys.map(k => [k, k === 'beta' ? { status: 'safety', grandfathered: true, reason: 'a guard' } : { status: 'untested', grandfathered: true, plan: 'none' }]));
describe('bin/gstack-inventory', () => {
test('determinism: two runs on a fixed tree write the same docs/inventory.md and the same --json', () => {
const dir = fixtureTree(path.join(tmp, 'fixed'), grandfathered);
const first = run(['--root', dir]);
expect(first.status, first.stderr).toBe(0);
const a = fs.readFileSync(path.join(dir, 'docs/inventory.md'), 'utf8');
const second = run(['--root', dir]);
expect(second.status, second.stderr).toBe(0);
expect(fs.readFileSync(path.join(dir, 'docs/inventory.md'), 'utf8')).toBe(a);
expect(run(['--root', dir, '--json']).stdout).toBe(run(['--root', dir, '--json']).stdout);
expect(a).toContain('Measured at commit none');
expect(a.indexOf('| alpha |')).toBeLessThan(a.indexOf('| beta |'));
const start = a.indexOf('## Measure next');
const next = a.slice(start, a.indexOf('\n## ', start + 1));
expect(next).toContain('alpha');
expect(next).not.toContain('| beta |');
expect(a).toContain('gstack as a whole');
expect(a).toContain('gstack-<name>');
for (const d of DISPATCHES) expect(a).toContain(`${d.file}:`);
});
test('--check: ok on a complete ledger; a missing key exits 1 with its code', () => {
const dir = fixtureTree(path.join(tmp, 'check'), grandfathered);
const ok = run(['--root', dir, '--check']);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^INVENTORY: ok keys=\d+/m);
const bad = fixtureTree(path.join(tmp, 'check-bad'), keys => { const p = grandfathered(keys); delete p.alpha; return p; });
const r = run(['--root', bad, '--check']);
expect(r.status).toBe(1);
expect(r.stderr).toContain('alpha: no row in docs/inventory/evidence.json (LEDGER_KEY_MISSING)');
expect(run(['--root', bad]).status).toBe(1);
});
test('--check: a cited dispatch source that lost its quote is named', () => {
const dir = fixtureTree(path.join(tmp, 'drift'), grandfathered);
fs.writeFileSync(path.join(dir, DISPATCHES[0]!.file), 'rewritten\n');
const r = run(['--root', dir, '--check']);
expect(r.status).toBe(1);
expect(r.stderr).toContain('(INVENTORY_SOURCE_MISSING)');
});
test('a bad --root, a root outside a gstack checkout and a gstack install are refused, and nothing is written', () => {
const dir = fixtureTree(path.join(tmp, 'args'), grandfathered);
for (const args of [['--root', '--json'], ['--root', '--check'], ['--root']]) expect(run(args).status).toBe(2);
expect(fs.existsSync(path.join(dir, 'docs/inventory.md'))).toBe(false);
const outside = spawnSync(BIN, [], { cwd: tmp, encoding: 'utf8', timeout: 120_000 });
expect(outside.status).toBe(2);
expect(outside.stderr).toContain('is not a gstack checkout');
const installed = fixtureTree(path.join(tmp, 'home', '.claude', 'skills', 'gstack'), grandfathered);
const w = run(['--root', installed]);
expect(w.status).toBe(2);
expect(w.stderr).toContain('refusing to write docs/inventory.md');
expect(fs.existsSync(path.join(installed, 'docs/inventory.md'))).toBe(false);
expect(run(['--root', installed, '--check']).status).toBe(0);
expect(installReason(dir, { GSTACK_CAPY_ROOT: dir })).toContain('the Capy install');
expect(installReason(dir, {}, path.join(tmp, 'home'))).toBeNull();
expect(installReason(path.join(tmp, 'capy-root'), {}, path.join(tmp, 'h'))).toBeNull();
});
test('the real ledger passes --check', () => {
const r = run(['--check']);
expect(r.status, r.stderr).toBe(0);
});
});
+56
View File
@@ -240,3 +240,59 @@ describe('lanes runtime and serial files (CD1)', () => {
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*2 tests ran in 2 file\(s\) \(1 serial\), exit 0/m);
});
});
// ---------------------------------------------------------------------------
// GST-1138 (ship-90-90 L6): the pre-push housekeeping run and gstack's own guard list
// ---------------------------------------------------------------------------
import { matchesAny } from '../lib/ship-policy';
describe('--stage guards: the pre-push housekeeping run (GST-1138)', () => {
// Value: protects=--stage guards runs only regen and guards for the touched files, writes its own record and never replaces the full run's
// pregate.json; fails_when=it runs the secrets, strays or test checks, overwrites .gstack/tmp/pregate.json, or accepts --only/--from-run;
// why_new=gbrain: 64 of 93 red heads were housekeeping (FOUN-64), #3908 went red on the context-budget ratchet; seam=gstack-pregate run; no_claim=CI
test('runs only regen and guards, writes pregate-guards.json, leaves pregate.json alone and refuses --only and --from-run', () => {
const repo = guardedFixture({ ...BASE_PREGATE, guards: [SERIAL_GUARD] });
commit(repo, { 'test/widget-env.serial.test.js': SERIAL_TEST });
const r = pregate(repo, ['--allow-repo-commands', '--stage', 'guards']);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toMatch(/^preflight {2}guards {4}fail .*serial-manifest.*\(PREGATE_GUARD_FAILED\)/m);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/pregate.json'))).toBe(false);
const rec = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate-guards.json'), 'utf8'));
expect(rec.checks.map((c: any) => c.id)).toEqual(['regen', 'guards']);
expect(rec.stage2_ms).toBeUndefined();
commit(repo, { 'scripts/serial-files.tsv': 'path\treason\ntest/widget-env.serial.test.js\tglobal-state\n' });
const ok = pregate(repo, ['--allow-repo-commands', '--stage', 'guards']);
expect(ok.status, ok.out + ok.err).toBe(EXIT.ok);
expect(ok.out).toContain('1 guard(s) passed: serial-manifest');
expect(pregate(repo, ['--stage', 'guards', '--only', 'secrets']).status).toBe(EXIT.usage);
expect(pregate(repo, ['--stage', 'guards', '--from-run', '123']).status).toBe(EXIT.usage);
});
});
describe("gstack's own pre-push guards (GST-1138)", () => {
// Value: protects=gstack declares the housekeeping guards that went red after push (typecheck, skill size, parity, context budget,
// module size, goldens, doc caps), each runnable and selected only by its inputs; fails_when=a guard is dropped, names a missing test file,
// fails validation, or fires on a docs-only change; why_new=GST-1138; seam=.gstack/pregate.json; no_claim=the guards' own verdicts
const GSTACK = path.resolve(import.meta.dir, '..');
const policy = JSON.parse(fs.readFileSync(path.join(GSTACK, '.gstack/pregate.json'), 'utf8'));
const guards: { id: string; when: string[]; command: string; fix?: string }[] = policy.guards ?? [];
const selected = (file: string) => guards.filter(g => matchesAny(g.when, file)).map(g => g.id).sort();
test('the list validates, every guard has a fix, and every test file it names exists', () => {
expect(codes(policy)).toEqual([]);
expect(guards.map(g => g.id).sort()).toEqual(['context-budget', 'doc-caps', 'goldens', 'module-size', 'parity-suite', 'skill-size', 'typecheck']);
for (const g of guards) {
expect(g.fix, g.id).toBeTruthy();
for (const f of g.command.match(/test\/[\w./-]+\.test\.ts/g) ?? []) expect(fs.existsSync(path.join(GSTACK, f)), `${g.id}: ${f}`).toBe(true);
}
});
test('each guard fires on its own inputs and none fires on a docs-only change', () => {
expect(selected('ship/SKILL.md.tmpl')).toEqual(['context-budget', 'goldens', 'parity-suite', 'skill-size']);
expect(selected('lib/pregate/index.ts')).toEqual(['module-size', 'typecheck']);
expect(selected('test/fixtures/golden/claude-ship-SKILL.md')).toEqual(['goldens']);
expect(selected('CLAUDE.md')).toEqual(['doc-caps']);
expect(selected('docs/pregate.md')).toEqual([]);
expect(selected('changelog.d/x.md')).toEqual([]);
});
});
+34
View File
@@ -122,6 +122,32 @@ describe('finish, push --early and accept (A1)', () => {
// Value: protects=finish never runs a mapped lane locally on the stamped tree: it defers it to accept and runs only pregate's preflight stage; accept carries
// it from the green run at the pushed head and reruns the pregate there; fails_when=the stamped tree reruns the suite, or accept trusts a run at another tree;
// why_new=design fix A1; seam=fake gh, gstack-evidence, gstack-pregate
test('accept checks a carried lane under the label it was recorded with: spaces and colons (TRUT-7)', () => {
const f = fx();
world(f);
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], unattended).status).toBe(0);
for (const label of ['pnpm lint', 'db:test']) {
const ok = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `${label}=${FREE}`, '--wait', '0']);
expect([ok.status, ok.stdout], label).toEqual([0, expect.not.stringContaining('not FRESH')]);
expect(ok.stdout).toMatch(/^EVIDENCE: FRESH /m);
}
for (const label of ['pnpm lint', 'db:test']) expect(f.run(path.join(BIN, 'gstack-evidence'), ['check', '--label', label]).stdout).toMatch(/^EVIDENCE: FRESH /m);
});
test('a carried row that no longer grades FRESH leaves no ci_carried entry (TRUT-7: pnpm lint)', () => {
const f = fx();
world(f);
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], unattended).status).toBe(0);
expect(f.run(RELEASE, ['accept', '--base', 'main', '--lane', `pnpm lint=${FREE}`, '--wait', '0']).status).toBe(0);
const carried = () => JSON.parse(f.run(path.join(BIN, 'gstack-evidence'), ['carried', '--base', 'main', '--json']).stdout);
expect(carried()).toEqual([expect.objectContaining({ lane: 'pnpm_lint', run: 7, attempt: 1 })]);
const [ledger] = findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl');
const rows = fs.readFileSync(ledger!, 'utf8').trim().split('\n').map(l => JSON.parse(l));
fs.writeFileSync(ledger!, rows.map(r => JSON.stringify(r.source === 'ci' ? { ...r, ts: at(30 * 60) } : r)).join('\n') + '\n');
expect(f.run(path.join(BIN, 'gstack-evidence'), ['check', '--label', 'pnpm lint', '--max-age', '24']).stdout).toMatch(/^EVIDENCE: STALE .* reason=older than 24h/m);
expect(carried()).toEqual([]);
});
test('finish defers the mapped lane; accept carries it from the green run at the pushed head', () => {
const f = fx();
world(f);
@@ -285,6 +311,14 @@ describe('GST-1189: a lane carries by named CI jobs instead of an inventory', ()
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: tree_mismatch)');
});
test('a pull_request run whose base moved names the one-command fix (TRUT-7)', () => {
const f = gbrain();
const mergeTree = f.git('rev-parse', 'origin/main^{tree}').stdout.trim();
const merge = f.git('commit-tree', mergeTree, '-p', 'HEAD', '-p', 'origin/main', '-m', 'Merge origin/main into feat').stdout.trim();
world(f, { jobs: ALL, run: { event: 'pull_request', head_branch: 'feat' }, tree: { ...NO_FILES, tree: mergeTree, sha: merge } });
expect(find(f).stdout).toContain("CI_CARRY: none (no_verified_run: ci/free: run 7: tree_mismatch (the pull request's base moved after the run; fix: gh workflow run free.yml --ref feat, then gstack-ci-carry wait))");
});
test('an entry declares exactly one of inventory or jobs', () => {
const both = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify'], inventory: 'printf x' }) }); fixtures.push(both);
world(both, { jobs: ALL, tree: NO_FILES });
+16
View File
@@ -380,4 +380,20 @@ describe('gstack-ship-preflight Step 5 pregate: head policy and timeout_s (GST-4
expect(lineOf(r.stdout, 'PREGATE')).toMatch(/^PREGATE: (tests |all )?incomplete/);
expect(lineOf(r.stdout, 'PREGATE_DETAIL')).toContain('timed out after 2000 ms (timeout_s from origin/main');
});
// Value: protects=--timeout on preflight reaches Step 5's pregate, so a repo with no timeout_s can run lanes past 600 s without a standalone pregate; fails_when=preflight drops the flag and pregate runs at timeout_s or the 600 s default;
// why_new=GST-1196 (GEVA-2, gstack-evals#176/#179): a 128-file group stopped at 600 s; seam=gstack-pregate --timeout; no_claim=the finish fallback
test('--timeout reaches Step 5 pregate over the policy and default, and a bad value is usage', () => {
const slow = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 4000)); });\n";
const f = fx(base({ timeout_s: 600 }), { 'test/slow.serial.test.js': slow });
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--timeout', '2', '--lane', LANE]);
expect(r.status).toBe(3);
expect(lineOf(r.stdout, 'PREGATE')).toMatch(/^PREGATE: (tests |all )?incomplete/);
expect(lineOf(r.stdout, 'PREGATE_DETAIL')).toContain('timed out after 2000 ms (--timeout applies');
for (const bad of ['0', '3601', '1.5', 'x']) {
const u = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--timeout', bad, '--lane', LANE]);
expect(u.status).toBe(2);
expect(u.stdout + u.stderr).toContain('--timeout needs whole seconds from 1 to 3600');
}
});
});
+96
View File
@@ -352,3 +352,99 @@ describe('GSTA-167 part 4a: the post-review route stays path-only', () => {
expect(r.stdout).toContain('STOP: behavior, tests or build inputs changed since review (wiki/post/data.json)');
});
});
describe('push: the pre-push housekeeping guards (GST-1138)', () => {
// Value: protects=push runs gstack-pregate --stage guards before every push and stops on a failing guard with its fix, pushing nothing;
// a pass recorded at HEAD's exact tree is reused instead of rerun; fails_when=a housekeeping red reaches CI, a failed guard still pushes,
// or a guard reruns on a tree it already passed; why_new=gbrain 64 of 93 red heads were housekeeping (FOUN-64), #3908's context-budget red;
// seam=gstack-pregate --stage guards; no_claim=CI's own verdict
const policy = (command: string) => JSON.stringify({ guards: [{ id: 'housekeeping', when: ['src/**'], command, fix: 'regenerate the golden' }] }) + '\n';
const ready = (f: ShipFx) => { fs.mkdirSync(f.state, { recursive: true }); fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), ''); };
const pushedHead = (f: ShipFx) => f.git('ls-remote', '--heads', 'origin', 'refs/heads/feat').stdout.trim();
test('a failing guard stops the push with its fix and nothing is pushed', () => {
const f = fx({ '.gstack/pregate.json': policy('echo golden is stale; exit 4') });
ready(f);
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(r.status).toBe(3);
expect(lineOf(r.stdout, 'GUARDS')).toMatch(/^GUARDS: fail .*housekeeping/);
expect(r.stdout).toContain('STOP: the pre-push guards did not pass');
expect(r.stdout).toContain('regenerate the golden');
expect(pushedHead(f)).toBe('');
});
test('a passing guard pushes; the next push at the same tree reuses the pass instead of rerunning it', () => {
const f = fx({ '.gstack/pregate.json': policy('echo ran >> ../guard-runs') });
ready(f);
const first = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(lineOf(first.stdout, 'GUARDS')).toMatch(/^GUARDS: pass .*1 guard\(s\) passed: housekeeping/);
expect(lineOf(first.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
const again = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(lineOf(again.stdout, 'GUARDS')).toMatch(/^GUARDS: pass \(reused from pregate-guards\.json at tree [0-9a-f]{12}: /);
expect(fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8')).toBe('ran\n');
f.write({ 'src/c.ts': 'export const c = 3;\n' }); f.commit('more');
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'GUARDS')).not.toContain('reused');
expect(fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8')).toBe('ran\nran\n');
});
test('an uncommitted or untracked file the guards saw stops the push: the verdict must describe the pushed tree', () => {
const f = fx({ '.gstack/pregate.json': policy('test -f src/needed.ts') });
ready(f);
f.write({ 'src/needed.ts': 'export const n = 1;\n' });
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(r.status).toBe(3);
expect(lineOf(r.stdout, 'GUARDS')).toContain('not certified');
expect(r.stdout).toContain('STOP: the working tree differs from HEAD');
expect(pushedHead(f)).toBe('');
});
test('a base that exists on origin but is not fetched, a mistyped --base, a dangling origin/HEAD and an unreadable policy stop the push', () => {
const f = fx({ '.gstack/pregate.json': policy('exit 4') });
ready(f);
f.git('update-ref', '-d', 'refs/remotes/origin/main');
const unfetched = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(unfetched.status).toBe(3);
expect(lineOf(unfetched.stdout, 'GUARDS')).toMatch(/^GUARDS: refused POLICY_SOURCE_UNAVAILABLE/);
expect(unfetched.stdout).toContain('STOP: the pre-push guards could not run');
f.git('fetch', '-q', 'origin', 'main:refs/remotes/origin/main');
const typo = f.run(RELEASE, ['push', '--branch', 'feat', '--base', 'mian']);
expect(typo.status).toBe(3);
expect(lineOf(typo.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/retired-default');
const dangling = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(dangling.status).toBe(3);
expect(lineOf(dangling.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
expect(pushedHead(f)).toBe('');
const broken = fx({ '.gstack/pregate.json': '{not json\n' });
ready(broken);
const refused = broken.run(RELEASE, ['push', '--branch', 'feat']);
expect(refused.status).toBe(3);
expect(lineOf(refused.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
});
test("finish's preflight record is reused only at HEAD's tree with passing regen and guards rows", () => {
const f = fx({ '.gstack/pregate.json': policy('echo ran >> ../guard-runs') });
ready(f);
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
const record = (at: string, guards: string) => JSON.stringify({ tree: at, checks: [{ id: 'regen', status: 'pass' }, { id: 'guards', status: guards }] });
const finishRecord = path.join(f.work, '.git', 'gstack', 'pregate-finish-preflight.json');
fs.mkdirSync(path.dirname(finishRecord), { recursive: true });
const runs = () => (fs.existsSync(path.join(f.root, 'guard-runs')) ? fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8') : '');
for (const [at, guards] of [[tree, 'fail'], ['0'.repeat(40), 'pass']]) {
fs.rmSync(path.join(f.work, '.git', 'gstack', 'pregate-guards.json'), { force: true });
fs.writeFileSync(finishRecord, record(at, guards));
fs.rmSync(path.join(f.root, 'guard-runs'), { force: true });
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'GUARDS')).not.toContain('reused');
expect(runs()).toBe('ran\n');
f.git('push', '-q', 'origin', '--delete', 'feat');
}
fs.rmSync(path.join(f.work, '.git', 'gstack', 'pregate-guards.json'), { force: true });
fs.writeFileSync(finishRecord, record(tree, 'pass'));
fs.rmSync(path.join(f.root, 'guard-runs'), { force: true });
const reused = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(lineOf(reused.stdout, 'GUARDS')).toMatch(/^GUARDS: pass \(reused from pregate-finish-preflight\.json at tree /);
expect(runs()).toBe('');
expect(lineOf(reused.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
});
});
+37 -2
View File
@@ -47,7 +47,7 @@ describe('gstack-skill-bill', () => {
const root = gstackRepo();
test('a gstack checkout skill reports every level and its last diet', () => {
const r = bill(root, ['demo']);
const r = bill(root, ['./demo']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('SKILL: demo (gstack-checkout)');
expect(r.stdout).toMatch(/template\s+SKILL\.md\.tmpl/);
@@ -58,7 +58,7 @@ describe('gstack-skill-bill', () => {
test('growth over 10% since the Skill-Diet commit prints DUE, and an unrendered template is RENDER_STALE', () => {
fs.appendFileSync(path.join(root, 'demo', 'SKILL.md.tmpl'), 'y'.repeat(200));
const r = bill(root, ['demo', '--json']);
const r = bill(root, ['./demo', '--json']);
const report = JSON.parse(r.stdout);
expect(report.baseline.status).toBe('found');
expect(report.baseline.due).toBe(true);
@@ -114,6 +114,41 @@ describe('gstack-skill-bill', () => {
});
});
describe('gstack-skill-bill EVIDENCE line (docs/inventory/evidence.json)', () => {
const root = path.join(tmp, 'evidence-repo');
const ledger = (parts: Record<string, unknown>) => {
fs.mkdirSync(path.join(root, 'docs', 'inventory'), { recursive: true });
fs.writeFileSync(path.join(root, 'docs', 'inventory', 'evidence.json'), JSON.stringify({ schema_version: 1, parts }));
};
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.writeFileSync(path.join(root, 'SKILL.md.tmpl'), 'router');
fs.writeFileSync(path.join(root, 'VERSION'), '1.0.0');
skill(path.join(root, 'demo'), 'demo', 'body\n', 'SKILL.md.tmpl');
test('each status prints its source, plan or reason; a skill with no row says so', () => {
ledger({ demo: { status: 'untested', grandfathered: true, plan: 'none' } });
expect(bill(root, ['./demo']).stdout).toContain('EVIDENCE: untested plan: none (grandfathered)');
ledger({ demo: { status: 'untested', reason: 'r', plan: 'test/fixtures/skill-evals/demo/tasks.json', owner: 'GSTA-1', measure_by: '2026-12-01' } });
expect(bill(root, ['./demo']).stdout).toContain('EVIDENCE: untested plan: test/fixtures/skill-evals/demo/tasks.json (owner GSTA-1, measure by 2026-12-01)');
ledger({ demo: { status: 'helps', sources: ['run-9/results.json'], models: ['claude-opus-5-5'], prompt: 'preset', scope: 's', masked: true } });
expect(bill(root, ['./demo']).stdout).toContain('EVIDENCE: helps sources: run-9/results.json');
ledger({ demo: { status: 'safety', reason: 'blocks rm -rf' } });
expect(bill(root, ['./demo']).stdout).toContain('EVIDENCE: safety reason: blocks rm -rf');
ledger({});
expect(bill(root, ['./demo']).stdout).toContain('EVIDENCE: missing: no demo row in docs/inventory/evidence.json');
expect(JSON.parse(bill(root, ['./demo', '--json']).stdout).evidence).toMatchObject({ key: 'demo', status: 'missing' });
fs.writeFileSync(path.join(root, 'docs', 'inventory', 'evidence.json'), '{bad');
const broken = bill(root, ['./demo']).stdout;
expect(broken).toContain('EVIDENCE: unreadable: cannot read docs/inventory/evidence.json');
expect(broken).not.toContain('add one');
});
test('the real checkout: a guard prints its safety reason', () => {
const r = spawnSync(BIN, ['careful'], { cwd: path.resolve(import.meta.dir, '..'), encoding: 'utf-8', timeout: 30_000 });
expect(r.stdout).toMatch(/^EVIDENCE: safety reason: .+/m);
});
});
describe('gstack-skill-bill baselines and the root skill', () => {
test('the gstack root skill is a checkout skill, and Skill-Diet: gstack-upgrade is not its diet', () => {
const root = path.join(tmp, 'gstack-root');
+7 -3
View File
@@ -102,8 +102,8 @@ telemetry — it never blocks the workflow.
## 1. Find the skill
Run `~/.claude/skills/gstack/bin/gstack-skill-bill <name|folder>`. It resolves exact names only and prints the
skill's kind and its size per level. A new skill: make the folder first (in a gstack checkout, follow CONTRIBUTING.md
Run `~/.claude/skills/gstack/bin/gstack-skill-bill <name|folder>`. It prints the skill's kind,
its size per level and its `EVIDENCE:` row. A new skill: make the folder first (in a gstack checkout, follow CONTRIBUTING.md
"Adding a new skill"), then write every line by sections 2 and 3.
- `user`: edit it in place.
- `gstack-install`: never edit; gstack-upgrade overwrites it. Edit `<skill>/SKILL.md.tmpl` in a gstack checkout, or
@@ -126,7 +126,9 @@ Put each fact on the lowest level that still reaches the agent before it needs i
Stop at the first step that handles the change.
1. **Fix it below the skill.** A clearer error, a flag, a check or a test in the tool. Prose is for judgment calls.
2. **Check the model needs it.** If a fresh session does the right thing without the line, add nothing.
2. **Earn its place.** Add a skill, step, instruction, check or default only with evidence it helps frontier models
or a dated plan to measure it (skill-evals `tasks.json` or an /eval-plan prereg). If a fresh session does it right
without the line, add nothing.
3. **Edit, don't append.** Change the line that already covers the idea; each meaning lives in one place.
4. **Pick its level** (section 2).
5. **Rule, not story.** One positive rule under a short name; the incident goes in the commit message.
@@ -138,6 +140,8 @@ When code changes a name or flag a skill teaches, update the skill in the same c
- Run `~/.claude/skills/gstack/bin/gstack-skill-bill` before and after, and put `Skill-Size: <before>→<after> bytes (<file>)` in the commit
message. DUE means it grew over 10% since its last `Skill-Diet:` commit: diet it instead of adding.
- Add `Evidence: <run id, results file or commit>`, `Evidence: plan <tasks.json or prereg> measure_by <date>` or
`Evidence: safety: <reason>`. A new gstack skill adds its `docs/inventory/evidence.json` row (`gstack-inventory --check`).
- In a gstack checkout: `bun test test/skill-bin-drift.test.ts test/parity-suite.test.ts` and the budgets in
CONTRIBUTING.md. A red context-budget ratchet means diet, not a higher ceiling.
- Rerun the case that prompted the change in a fresh session.
+7 -3
View File
@@ -29,8 +29,8 @@ allowed-tools:
## 1. Find the skill
Run `~/.claude/skills/gstack/bin/gstack-skill-bill <name|folder>`. It resolves exact names only and prints the
skill's kind and its size per level. A new skill: make the folder first (in a gstack checkout, follow CONTRIBUTING.md
Run `~/.claude/skills/gstack/bin/gstack-skill-bill <name|folder>`. It prints the skill's kind,
its size per level and its `EVIDENCE:` row. A new skill: make the folder first (in a gstack checkout, follow CONTRIBUTING.md
"Adding a new skill"), then write every line by sections 2 and 3.
- `user`: edit it in place.
- `gstack-install`: never edit; gstack-upgrade overwrites it. Edit `<skill>/SKILL.md.tmpl` in a gstack checkout, or
@@ -53,7 +53,9 @@ Put each fact on the lowest level that still reaches the agent before it needs i
Stop at the first step that handles the change.
1. **Fix it below the skill.** A clearer error, a flag, a check or a test in the tool. Prose is for judgment calls.
2. **Check the model needs it.** If a fresh session does the right thing without the line, add nothing.
2. **Earn its place.** Add a skill, step, instruction, check or default only with evidence it helps frontier models
or a dated plan to measure it (skill-evals `tasks.json` or an /eval-plan prereg). If a fresh session does it right
without the line, add nothing.
3. **Edit, don't append.** Change the line that already covers the idea; each meaning lives in one place.
4. **Pick its level** (section 2).
5. **Rule, not story.** One positive rule under a short name; the incident goes in the commit message.
@@ -65,6 +67,8 @@ When code changes a name or flag a skill teaches, update the skill in the same c
- Run `~/.claude/skills/gstack/bin/gstack-skill-bill` before and after, and put `Skill-Size: <before>→<after> bytes (<file>)` in the commit
message. DUE means it grew over 10% since its last `Skill-Diet:` commit: diet it instead of adding.
- Add `Evidence: <run id, results file or commit>`, `Evidence: plan <tasks.json or prereg> measure_by <date>` or
`Evidence: safety: <reason>`. A new gstack skill adds its `docs/inventory/evidence.json` row (`gstack-inventory --check`).
- In a gstack checkout: `bun test test/skill-bin-drift.test.ts test/parity-suite.test.ts` and the budgets in
CONTRIBUTING.md. A red context-budget ratchet means diet, not a higher ceiling.
- Rerun the case that prompted the change in a fresh session.