promptwheel 0.7.16 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INTEGRATE.md CHANGED
@@ -50,7 +50,7 @@ importing this package.
50
50
  "gamed": true,
51
51
  "sourceOnly": null,
52
52
  "retained": 0.0,
53
- "gamingReason": "the win changed zero production-source files, only test/config/grader/golden"
53
+ "gamingReason": "the \"win\" changed zero production-source files, only test/config/grader/golden"
54
54
  }
55
55
  ]
56
56
  }
@@ -68,15 +68,18 @@ metric's gain that survived the source-only re-run (Section 3). `confidence`
68
68
  (`none | unverified | low | medium | high`, `none` for an unmeasurable metric) and `sourceOnly` are informational fields this
69
69
  implementation adds; a minimal conforming report may omit them. `gate` (optional) records a
70
70
  truncated SHA-256 fingerprint of the gate definition (metrics, guards, `evaluatorPaths`,
71
- `gamingThreshold`, `mandates`) at base and head, sets `gate.changed: true` when they differ,
72
- and lists any guard the change dropped to hide a regression; a non-empty `gate.weakened`
73
- forces `gamed`, so an agent cannot pass by disabling the guard it is judged by.
74
-
75
- Self-check your implementation against this schema (see Section 4 below):
76
-
77
- ```bash
78
- your-tool --json | promptwheel conformance -
79
- ```
71
+ `gamingThreshold`, `mandates`, `setup`, `env`, linked dirs; each metric with its `cmd` and how it
72
+ is read) at base and head, sets `gate.changed: true` when they differ, and lists in
73
+ `gate.weakened` any guard the change removed, disabled, or measured another way
74
+ (`change: guard-edited`, such as `cmd: "true"`; `change: measurement-edited`, a new `setup`, `env`
75
+ or `linkDirs`) to hide a regression; a non-empty `gate.weakened` forces `gamed`, so an agent
76
+ cannot pass by disabling the guard it is judged by. A change that adds the first config is
77
+ compared with the zero-config gate (the detected test command). `allowed` (optional) is `{ reason, cleared }` when a declared
78
+ reason turned a would-be `gamed`, `fail` or `inconclusive` into `pass`: `cleared` names each metric,
79
+ `gate`, or `mandate:<name>` it cleared, and each cleared metric carries `allowed: true` and keeps its
80
+ `gamingReason`, so a consumer can say what was allowed and why it would have failed.
81
+
82
+ Self-check your implementation against this schema with `conformsToContract` (see part 4 below).
80
83
 
81
84
  ---
82
85
 
@@ -103,8 +106,8 @@ measurement runs in a throwaway git worktree.
103
106
  ## 3. Import the library (in process)
104
107
 
105
108
  Zero dependencies, no build step, the whole engine is one file. `check()` runs the gate in
106
- process and returns the Section 2 object. It never prints and never calls `process.exit`; it
107
- throws a plain `Error` on a precondition failure. Types ship in the package
109
+ process and returns the Section 2 object. It never prints and never calls `process.exit`; on a
110
+ precondition failure it throws an `Error` named `ConfigError` with `exitCode` 64. Types ship in the package
108
111
  (`bin/promptwheel.d.ts`, hand-written, no build step): `import type { Report, CheckOptions, Config } from 'promptwheel'`
109
112
  works in TypeScript, and editors complete the options and the report in plain JavaScript.
110
113
 
@@ -135,7 +138,6 @@ Returns the Section 2 verdict object (identical to `run --json`). Reproducible f
135
138
  | `repeat` | number | `config.repeat ?? 1` | measurement repeats for the noise band |
136
139
  | `detectGaming` | boolean | `true` | run the Section 3 source-only re-proof |
137
140
  | `allow` | string | none | a declared override reason (the same as `--allow` / a `Promptwheel-Allow:` trailer): a would-be `gamed`, or an `unprovable` win (its source-only re-run could not be applied), reads `pass` with the reason recorded in `report.allowed` |
138
- | `record` | boolean | `false` | append the run to `.promptwheel/outcomes.jsonl` |
139
141
 
140
142
  Preconditions throw (they do not exit the host): not inside a git repository; `auto: false`
141
143
  with no `promptwheel.config.json` and no `config` option; a `config` with no non-empty
@@ -173,22 +175,19 @@ of truth instead of hardcoding the strings.
173
175
 
174
176
  ---
175
177
 
176
- ## 4. Self-check with `promptwheel conformance`
178
+ ## 4. Self-check with `conformsToContract`
177
179
 
178
180
  Prove your own check, loop, or CI step speaks the Contract.
179
181
 
180
- ```bash
181
- # validate a verdict JSON (from a file or stdin) against the Section 2 schema
182
- promptwheel conformance report.json
183
- cat report.json | promptwheel conformance -
182
+ ```js
183
+ import { conformsToContract, verdictExitCode } from 'promptwheel';
184
184
 
185
- # run a command, then validate BOTH its --json stdout (Section 2)
186
- # AND that its exit code matches the verdict (Section 1)
187
- promptwheel conformance --cmd "your-tool --json"
185
+ const { ok, errors } = conformsToContract(JSON.parse(reportJson)); // Section 2
186
+ const exitMatches = exitCode === verdictExitCode(report.verdict); // Section 1
188
187
  ```
189
188
 
190
- Exit `0` if it conforms, `1` otherwise, listing every problem. This is how a third-party
191
- implementation earns the right to say "Verified-Outcome Contract compatible."
189
+ `ok` is `true` when the report conforms; otherwise `errors` lists every problem. This is how a
190
+ third-party implementation earns the right to say "Verified-Outcome Contract compatible."
192
191
 
193
192
  ---
194
193
 
package/README.md CHANGED
@@ -23,8 +23,10 @@ printf "import {test} from 'node:test'; import assert from 'node:assert'; import
23
23
  git commit -qam 'make the suite green'
24
24
 
25
25
  npx -y promptwheel@latest run --base HEAD~1 --head HEAD
26
- # ▲ tests_pass 0 → 1 🚩 GAMED, the win changed zero production-source files
27
- # VERDICT: GAMED (exit 2)
26
+ # prints (shortened to the lines that matter; exit code 2):
27
+ # ▲ tests_pass 0 → 1 (+1, improved) [guard✓, high]
28
+ # 🚩 GAMED, the "win" changed zero production-source files, only test/config/grader/golden
29
+ # VERDICT: GAMED, a metric "improved" by editing the goalposts, not the source
28
30
  ```
29
31
 
30
32
  What happened: PromptWheel measured `tests_pass` at both commits in throwaway git worktrees (0 then 1, a win), then split the diff into production source versus `{test, config, grader, golden}` files, re-applied **only the source slice** on a clean worktree at the base, and re-ran the metric. The win did not survive, so it is `GAMED`. No LLM, no network: a diff partition plus a re-run, reproducible in seconds with a human-readable reason.
@@ -54,18 +56,11 @@ npx promptwheel run --working # gate UNCOMMITTED changes (incl. newl
54
56
  npx promptwheel run --repeat 5 --json # measure 5× to establish a noise band, emit JSON
55
57
 
56
58
  npx promptwheel init # optional: write promptwheel.config.json (guarded tests + antihack tripwires, + lint if eslint is set up)
57
- npx promptwheel init --list # presets: tests-pass · lint · bundle-size · security · llm-eval · antihack · no-ai-tells · security-antihack · supply-chain
58
-
59
- # the loop: run any agent/script, keep the change ONLY if a metric improved, else revert
60
- npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
61
- # exit 0 = kept a real win · 1 = guarded regression (reverted) · 2 = gamed (reverted) · 3 = plateau / inconclusive (reverted) · add --json
62
- while npx promptwheel improve --attempt "<your agent command>"; do :; done # converges: stops on the first non-zero
63
-
64
- npx promptwheel insights # which metrics actually respond in this repo (counts from .promptwheel/outcomes.jsonl)
65
- npx promptwheel guards # the effective guardrails, including ones inherited through extends
59
+ npx promptwheel init --list # presets: antihack · no-ai-tells · supply-chain
60
+ npx promptwheel lock # write promptwheel.lock.json, the shape of this repo; the supply-chain preset holds it
66
61
  ```
67
62
 
68
- Gaming detection is on by default in `run` and `improve`; `--no-detect-gaming` gives you the bare outcome gate.
63
+ Gaming detection is on by default in `run`; `--no-detect-gaming` gives you the bare outcome gate.
69
64
 
70
65
  **Give your agent the gate.** `promptwheel init` writes this into your `AGENTS.md` (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), so the coding agent runs the gate itself. To paste it yourself:
71
66
 
@@ -74,7 +69,7 @@ Gaming detection is on by default in `run` and `improve`; `--no-detect-gaming` g
74
69
  config, or grader, not the source, so revert and earn it in the source. Exit 1 means a guarded
75
70
  metric regressed, revert that too. Never weaken, skip, or delete a test to go green.
76
71
 
77
- **Two callers, by design.** Your *agent* consumes the verdict as a per-turn signal (`improve`, or `run --working --json`). The *harness* (a Stop-hook, or CI) runs the same check as an audit the agent cannot self-clear, because the check re-proves from the source alone and never asks the code to grade itself. See [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md) for the three places to enforce it.
72
+ **Two callers, by design.** Your *agent* consumes the verdict as a per-turn signal (`run --working --json`). The *harness* (a Stop-hook, or CI) runs the same check as an audit the agent cannot self-clear, because the check re-proves from the source alone and never asks the code to grade itself. See [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md) for the three places to enforce it.
78
73
 
79
74
  ## Config, `promptwheel.config.json`
80
75
 
@@ -95,11 +90,11 @@ Gaming detection is on by default in `run` and `improve`; `--no-detect-gaming` g
95
90
  - **extract**, reduce its output to a number: `number` (last number, default) · `lines` (count non-empty lines) · `exit` (1 if exit 0 else 0) · `{ "regex": "coverage: (\\d+)" }` (first capture).
96
91
  - **direction**, `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
97
92
  - **guard**, `true` = a *trusted* regression **fails** the gate; `false` = informational.
98
- - **gamingCheck**, `false` exempts a metric from the source-only re-run. Use it for tripwire / test-side guards (assertion counts, test counts) whose gains legitimately live in test files, otherwise adding real tests would be flagged as gaming. The `antihack` preset sets this on its tripwires.
99
- - **gamingThreshold**, the fraction of a win that must survive the source-only re-run to count as earned (default `0.5`). Config-level scalar, overridable per metric; inherited through `extends` like `repeat`.
93
+ - **gamingCheck**, `false` exempts a metric from the source-only re-run. Use it for tripwire / test-side guards (assertion counts, test counts) whose gains legitimately live in test files, otherwise adding real tests would be flagged as gaming. The `antihack` preset sets this on its tripwires. Set on a metric that had the re-run on, it takes effect from the next change: the change that sets it is still re-proven.
94
+ - **gamingThreshold**, the fraction of a win that must survive the source-only re-run to count as earned (default `0.5`). Config-level scalar, overridable per metric; inherited through `extends` like `repeat`. A change that lowers it is still judged by the value before it.
100
95
  - **evaluatorPaths**, globs for files that *define* your gate (a threshold file, a budget, a CI limit). Editing one in the same diff that improves a metric is `GAMED` by declaration, deterministically. Opt-in, because only you know which files are the goalposts.
101
96
 
102
- - **allowable**, `true` lets a declared reason (`--allow "<reason>"`, a `Promptwheel-Allow:` commit trailer, `check({ allow })`) clear *this* metric's guarded regression for that run, recorded in `report.allowed` and on the metric as `allowed: true`. For counts where a rise is sometimes the job (the `supply-chain` preset: a new dependency), so the change passes with the reason on the record instead of the guard being loosened. Without a reason the guard fails as usual.
97
+ - **allowable**, `true` lets a declared reason (`--allow "<reason>"`, a `Promptwheel-Allow:` commit trailer, `check({ allow })`) clear *this* metric's guarded regression for that run, recorded in `report.allowed` and on the metric as `allowed: true`. `"flag"` takes the reason from `--allow` (or `check({ allow })`) only, never a trailer, because an agent can write a trailer. For counts where a rise is sometimes the job (the `supply-chain` preset uses `"flag"`: a new dependency), so the change passes with the reason on the record instead of the guard being loosened. Without a reason the guard fails as usual.
103
98
 
104
99
  Dependency and environment keys (config-level; `init` writes stack defaults):
105
100
 
@@ -107,6 +102,8 @@ Dependency and environment keys (config-level; `init` writes stack defaults):
107
102
  - **env**, environment variables for every metric command; `{wt}` is substituted with the worktree path. Python configs get `"PYTHONPATH": "{wt}/src:{wt}"` so the *measured ref* is imported, not an editable install of your original checkout.
108
103
  - **setup**, an optional command run once per ref before metrics (for example `npm run build` or `pip install -e .`), after any source patch, so a build-gated suite measures the ref it is actually on.
109
104
 
105
+ These three shape every measurement, so they are part of the gate. A change that edits them is flagged as a gate change and is judged with the values from before it (see Gate integrity below).
106
+
110
107
  Copy a config for your stack (pytest, tsc, coverage, bundle size, LLM eval) from [examples/](examples/).
111
108
 
112
109
  ### Guardrails and inheritance
@@ -119,11 +116,11 @@ Teams keep one shared base config and have every repo inherit it via `extends`:
119
116
  "metrics": [ { "name": "cost_per_run_usd", "guard": false } ] } // loosen one inherited guard, locally
120
117
  ```
121
118
 
122
- `extends` takes a path (or array of paths) to base configs: a repo **inherits** their guardrails, and a local metric of the same name **overrides** the inherited one (tighten, loosen, or disable). `promptwheel guards` shows the effective set with provenance (`inherited ← base.json`, `local`, or `local override`) plus each guard's flag record from the outcome record. Read this way, `extends` is the set of shared invariants your agents are held to, enforced as measured guards rather than advisory text. Natural-language conventions belong in `AGENTS.md`; only deterministic, measurable guards belong here.
119
+ `extends` takes a path (or array of paths) to base configs: a repo **inherits** their guardrails, and a local metric of the same name **overrides** the inherited one (tighten, loosen, or disable). Read this way, `extends` is the set of shared invariants your agents are held to, enforced as measured guards rather than advisory text. Natural-language conventions belong in `AGENTS.md`; only deterministic, measurable guards belong here.
123
120
 
124
121
  ### Mandates (protected paths)
125
122
 
126
- Some rules are not a metric at all, they are a hard line: *never touch `src/auth/**`*, *the migration files are frozen*. Declare those as `mandates`, and any change that touches a protected path **fails the gate before a single metric is read**, however green everything else is:
123
+ Some rules are not a metric at all, they are a hard line: *never touch `src/auth/**`*, *the migration files are frozen*. Declare those as `mandates`, and any change that touches a protected path **fails the gate**, however green everything else is. The metrics still run and are reported, but nothing they say clears a mandate:
127
124
 
128
125
  ```json
129
126
  // promptwheel.config.json
@@ -133,7 +130,7 @@ Some rules are not a metric at all, they are a hard line: *never touch `src/auth
133
130
  "metrics": [ /* ... */ ] }
134
131
  ```
135
132
 
136
- A mandate is deterministic (a path match on the diff, zero-LLM), rides at the top of the report, and **inherits through `extends`** so a parent scope's hard rule holds for every repo beneath it. It is folded into the gate fingerprint at both refs, so quietly deleting a mandate changes the fingerprint: the report records `gate.changed: true` and prints a "gate definition changed" line, a tracked, tamper-evident change. And deleting it does not clear it: the mandates in force are the union of the base ref's and the head's, so a change that removes a mandate and touches its path in the same diff still fails (reported with `removed: true`). Retire a mandate in its own change, with the protected path untouched. (Before 0.7.5 the base-ref fingerprint did not include mandates, so this was recorded backwards: an unchanged mandate read as a changed gate and a deleted one did not, and the deletion cleared the rule.) This is the line between a contract and a request: `AGENTS.md` can *ask* an agent not to touch auth; a mandate will not let the change past the gate.
133
+ A mandate is deterministic (a path match on the diff, zero-LLM), rides at the top of the report, and **inherits through `extends`** so a parent scope's hard rule holds for every repo beneath it. It is folded into the gate fingerprint at both refs, so quietly deleting a mandate changes the fingerprint: the report records `gate.changed: true` and prints a "gate definition changed" line, a tracked, tamper-evident change. And deleting it does not clear it: the mandates in force are the ones the base ref already had, so a change that removes a mandate and touches its path in the same diff still fails (reported with `removed: true`), and one that narrows a mandate is checked against both versions (`edited: true`). A mandate added in the same change that touches its path does not apply to that change, on purpose: the commit that signs a contract adds the very files the contract protects. Retire a mandate in its own change, with the protected path untouched. (Before 0.7.5 the base-ref fingerprint did not include mandates, so this was recorded backwards: an unchanged mandate read as a changed gate and a deleted one did not, and the deletion cleared the rule.) This is the line between a contract and a request: `AGENTS.md` can *ask* an agent not to touch auth; a mandate will not let the change past the gate.
137
134
 
138
135
  ### Supply chain (preset `supply-chain`)
139
136
 
@@ -143,7 +140,7 @@ The step every npm supply-chain attack needs inside a target repo is a supply-ch
143
140
  npx promptwheel init --preset supply-chain
144
141
  ```
145
142
 
146
- writes `promptwheel.lock.json` (the shape of the repo, from `promptwheel lock`) and seven guarded counts that must not rise (`lifecycle_scripts`, `dependency_entries`, `lock_nonregistry_sources`, `unpinned_execution`, `install_policy_gaps`, `lock_drift`, `pins_changed`, measured by a zero-dep pack copied into `.promptwheel/packs/supply-chain/`), mandates that freeze `.github/workflows/**`, `.npmrc` and the yarn rc files, `Dockerfile*` and `docker-compose*`, `scripts/release*` and `scripts/publish*`, `CODEOWNERS`, and the Claude Code hook files, and lists the contract itself (`promptwheel.config.json`, `sawbuck.config.json`) under `evaluatorPaths`, so a change that edits it alongside a win is gamed by declaration while a change that only edits the contract still passes. When the rise is the job, say so and it is recorded: `promptwheel run --allow "needed for the CSV export (papaparse)"`. It is not a dependency firewall and not an injection detector: it does nothing about a package already compromised on the registry, and it does not decide where an instruction came from. Details in [packs/supply-chain/README.md](packs/supply-chain/README.md).
143
+ writes `promptwheel.lock.json` (the shape of the repo, from `promptwheel lock`) and seven guarded counts that must not rise (`lifecycle_scripts`, `dependency_entries`, `lock_nonregistry_sources`, `unpinned_execution`, `install_policy_gaps`, `lock_drift`, `pins_changed`, measured by a zero-dep pack copied into `.promptwheel/packs/supply-chain/`), mandates that freeze `.github/workflows/**`, `.npmrc` and the yarn rc files, `Dockerfile*` and `docker-compose*`, `scripts/release*` and `scripts/publish*`, `CODEOWNERS`, the copied scanners, the Claude Code hook files, the lock file, and the contract itself (`promptwheel.config.json`, `sawbuck.config.json`). A change that edits the contract fails (exit 1) unless a person passes `--allow "<reason>"`; the contract is also an `evaluatorPaths` entry, so editing it alongside a win is gamed by declaration. `--allow` clears only the contract, the lock and the hooks. Every other frozen path needs its mandate retired first, by a person, in a change of its own run with `--allow`. When the rise is the job, say so and it is recorded: `promptwheel run --allow "needed for the CSV export (papaparse)"`. It is not a dependency firewall and not an injection detector: it does nothing about a package already compromised on the registry, and it does not decide where an instruction came from. Details in [packs/supply-chain/README.md](packs/supply-chain/README.md).
147
144
 
148
145
  ## In CI and in Claude Code
149
146
 
@@ -173,20 +170,20 @@ The Action runs straight from its own checkout, no npm install, no build. See [`
173
170
  /plugin install promptwheel@promptwheel-ai
174
171
  ```
175
172
 
176
- Then `/promptwheel:setup` (write a config) · `/promptwheel:gate` (gate uncommitted changes) · `/promptwheel:improve <cmd>` (keep-if-improved) · `/promptwheel:insights` · `/promptwheel:logbook`. See [`plugins/promptwheel/`](plugins/promptwheel/). A Stop-hook that refuses to let an agent end a turn on a `GAMED` win ships at [`examples/detect-gaming-stop.sh`](examples/detect-gaming-stop.sh).
173
+ Then `/promptwheel:setup` (write a config) · `/promptwheel:gate` (gate uncommitted changes). See [`plugins/promptwheel/`](plugins/promptwheel/). A Stop-hook that refuses to let an agent end a turn on a `GAMED` win ships at [`examples/detect-gaming-stop.sh`](examples/detect-gaming-stop.sh).
177
174
 
178
175
  ## Trust model
179
176
 
180
- **What it catches.** After the normal gate, every *win* is re-proven using the agent's source edits alone. If the win only survived by skipping or deleting a test, mocking the grader, editing a golden, relaxing a declared config, or touching zero source files, the verdict is `GAMED` (exit 2). On top of that:
177
+ **What it catches.** After the normal gate, every *win* is re-proven using the agent's source edits alone. If the win only survived by skipping or deleting a test, mocking the grader, editing a golden, relaxing a declared config, or touching zero source files, the verdict is `GAMED` (exit 2). A grader is recognized by its file name in a scripting language (`eval.py`, `grader.sh`, `score.js`, `scoring.ts`) or by an `evals/` or `grader(s)/` directory; a production file with one of those names (`src/score.py`) is treated as a grader too, so a fix there needs `--allow`, and a grader with any other name (`evaluate.py`, `check.py`) is source unless you list it in `evaluatorPaths`. On top of that:
181
178
 
182
179
  - **Tripwire guards** (`test_count`, `skipped_tests`, `suppressions`, `assertions`, written by the plain `init` default) catch the quieter shapes: an inline suppression (`@ts-nocheck`, `eslint-disable`, `# noqa`) or a suite gutted while the metric stays flat.
183
- - **Gate integrity.** The gate is resolved as of the base commit too. A change that disables a guard or deletes a guarded metric in the same diff it passes is re-measured under the base definition and reads `GAMED` if that guard would have failed. The gate definition (metrics, guards, `evaluatorPaths`, `gamingThreshold`, `mandates`) is fingerprinted at both refs; a difference is recorded as `gate.changed` and printed.
180
+ - **Gate integrity.** A change is judged by the gate that was in force before it. The gate is resolved as of the base commit too, through `extends`. A change that disables a guard, deletes a guarded metric, or measures it another way (a new `cmd` such as `true`, `extract`, `direction`, `exact` or `report`) in the same diff it passes is re-measured under the base definition and reads `GAMED` if that guard would have failed, or would still fail. If the old guard only passes because the same change also edited the test, it reads `GAMED` too: a win under the old definition still has to survive the source-only re-run. A file declared in `evaluatorPaths` before the change still counts as a goalpost for that change, even if the change drops the declaration. The same goes for `setup`, `env` and `linkDirs`: when a change edits them, each guard is measured again with the old values and so is the source-only re-run, so a `setup` that rewrites a test cannot carry a pass (`gate.weakened[].change: measurement-edited`). `gamingCheck: false` on a metric that had the re-run on, and a lower `gamingThreshold`, apply from the next change. A change that adds the first config is compared with the zero-config gate (the detected test command), so `tests_pass` set to `true` in a new config is an edited guard; a first config that keeps the detected command, or replaces the placeholder, is not. The gate definition (each metric with its command and how it is read, guards, `evaluatorPaths`, `gamingThreshold`, mandate names and paths, `setup`, `env`, linked dirs) is fingerprinted at both refs; a difference is recorded as `gate.changed` and printed. Two limits: a test that could not run at all before a `setup` fix reads `GAMED` on the change that adds the fix (say so with `--allow`; the `setup` that `init` writes into a first config is exempt), and a base config that `extends` a file outside the repository is not compared on `setup`, `env` or `linkDirs`.
184
181
  - **A sanctioned override.** A commit trailer can be written by whoever writes the commit, including an agent, so `"allowTrailer": false` in the contract turns trailers off and only the `--allow` flag counts (the supply-chain preset sets this; the contract at the base ref decides, so a change cannot turn trailers back on for itself). When you *do* mean to change a test, config, or grader, declare it: a `Promptwheel-Allow: <reason>` trailer on the commit (`Sawbuck-Allow:` is accepted as an alias), `--allow "<reason>"` on the CLI, or the `allow` option on `check()`, turns what would read `GAMED` into a `pass` with the reason recorded in `report.allowed`. It covers a win whose source-only re-run could not be applied (`unprovable`, otherwise `inconclusive`) the same way. An undeclared test-only win is still `gamed`, and a reasonless declaration does not override. The check never judges intent; it forces intent to be explicit and auditable.
185
182
  - **A provenance receipt** on every verdict (`report.provenance`: base and head SHAs, a config hash, a verdict hash), so a skeptic can re-run `(base, head, config)` and get the same hash.
186
183
 
187
- **The honest limit.** This is one layer, not a silver bullet. It catches the evaluator-tampering class (test, grader, golden and config edits) deterministically and for free. A suite weakened while it stays green is not proved: there is no win to re-run. Tripwires catch skips and removed assertions in a signed repo. It is blind, by construction, to a cheat that lives in the source file itself (a hardcoded answer, behavior that differs under test): that needs held-out tests, an LLM judge, or a human, and those expensive layers should be reserved for the calls only they can make. The coverage matrix and the in/out-of-scope boundary are in [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md); the measured numbers are in [bench/RESULTS.md](bench/RESULTS.md) (`node bench/gaming-bench.mjs` to reproduce). Another gameable thing, an LLM judge asking "did you cheat?", is deliberately not part of the check.
184
+ **The honest limit.** This is one layer, not a silver bullet. It catches the evaluator-tampering class (test, grader, golden and config edits) deterministically and for free. A suite weakened while it stays green is not proved: there is no win to re-run. Tripwires catch skips and removed assertions in a signed repo. It is blind, by construction, to a cheat that lives in the source file itself (a hardcoded answer, behavior that differs under test), or in a build script the unchanged `setup` runs (a change that makes that script rewrite a test is a source edit to this check). A base config that `extends` a file outside the repository cannot be compared across the change. These need held-out tests, an LLM judge, or a human, and those expensive layers should be reserved for the calls only they can make. The coverage matrix and the in/out-of-scope boundary are in [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md); the measured numbers are in [bench/RESULTS.md](bench/RESULTS.md) (`node bench/gaming-bench.mjs` to reproduce). Another gameable thing, an LLM judge asking "did you cheat?", is deliberately not part of the check.
188
185
 
189
- **Noise.** A number that jumps around between runs is worthless as a signal, and PromptWheel does not pretend otherwise: `--repeat N` measures each metric N times at both refs and uses the median; the noise band is the observed spread; a delta inside the band is `inconclusive` with `low` confidence and does not fail a guard (no flaky CI failures). Confidence reads `high` (deterministic extract, or zero observed noise), `medium` (delta clears the noise band), `low` (delta inside noise), or `unverified` (single read; run `--repeat` to earn trust). A guard whose command never ran (same value at both refs) makes the verdict `inconclusive`, never a fake pass.
186
+ **Noise.** A number that jumps around between runs is worthless as a signal, and PromptWheel does not pretend otherwise: `--repeat N` measures each metric N times at both refs and uses the median; the noise band is the observed spread; a delta inside the band is `inconclusive` with `low` confidence and does not fail a guard (no flaky CI failures). Confidence reads `high` (deterministic extract, or zero observed noise), `medium` (delta clears the noise band), `low` (delta inside noise), or `unverified` (single read; run `--repeat` to earn trust). A guarded pass/fail check that reads 0 at both refs (its command never ran, or it ran and failed both times; the report says which) makes the verdict `inconclusive`, never a fake pass.
190
187
 
191
188
  **When not to use it.** If you only care that tests pass and nobody could be editing the tests, your plain CI already has you covered. PromptWheel earns its place when the thing writing the code can also touch the thing judging it, or when you have a graded metric beyond pass/fail (an eval score, cost per run, latency, size) that a change could quietly move. Single-metric CI gates (benchmark trackers, size limits) go deeper on one number; wrap them as a metric `cmd` and PromptWheel composes them into one verdict.
192
189
 
@@ -194,7 +191,7 @@ Then `/promptwheel:setup` (write a config) · `/promptwheel:gate` (gate uncommit
194
191
 
195
192
  A check runs your metric commands in two throwaway worktrees (before and after) plus a source-only re-run when a metric improved, so one check takes roughly 2 to 3× your metric command's runtime plus about 40ms of worktree overhead. Point the per-turn gate at a *fast* metric (a lint count, a quick test subset, a build-size check) and leave the slow full suite for CI: a 50ms check is invisible in a loop, a two-minute one is not. A deterministic metric (a test exit, a count) is accurate at the default `repeat: 1`; only a noisy metric (latency, timing) needs `repeat: 3` or more, which multiplies the cost.
196
193
 
197
- **Footprint.** It never touches your working tree: every measurement runs in a throwaway git worktree in your system temp dir (one at a time; configured `linkDirs` are symlinked, not copied), removed when the run finishes. The only thing PromptWheel writes to your repo is the optional `.promptwheel/outcomes.jsonl` record; commit it, `.gitignore` it, or pass `--no-record`. A hard-killed run cannot leave clutter behind: the next run self-heals any orphaned worktree.
194
+ **Footprint.** It never touches your working tree: every measurement runs in a throwaway git worktree in your system temp dir (one at a time; configured `linkDirs` are symlinked, not copied), removed when the run finishes. A run writes nothing to your repo; only `init` and `lock` write files, the ones you commit. A hard-killed run can leave a worktree in your temp dir; a later run removes it once it is a day old (an unregistered checkout of this repo after an hour). It removes only directories named the way it names its own (`promptwheel-`, `promptwheel-src-` or `pw-idx-`, then an 8-hex repo tag, then 6 random characters). Any other folder or worktree in the temp dir is left alone, at any age.
198
195
 
199
196
  ## Portability
200
197
 
@@ -202,9 +199,9 @@ The core gamed-win check is git only and runs anywhere git does: it classifies t
202
199
 
203
200
  ## Docs
204
201
 
205
- - [INTEGRATE.md](INTEGRATE.md), the integration surface: the exit-code and verdict-JSON contract, shelling `--json`, `import { check }`, and `promptwheel conformance` for self-checking a third-party implementation.
202
+ - [INTEGRATE.md](INTEGRATE.md), the integration surface: the exit-code and verdict-JSON contract, shelling `--json`, `import { check }`, and `conformsToContract` for self-checking a third-party implementation.
206
203
  - [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md), how the source-only re-run fits as the deterministic layer alongside held-out tests, LLM judges, and human review: the coverage matrix and the honest in/out-of-scope boundary.
207
- - [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md), making "the agent can't skip it" real: the three places to enforce (loop-revert, CI + branch protection, a Claude Code Stop-hook), the exact wiring, and the protect-the-gate's-own-config caveat.
204
+ - [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md), making "the agent can't skip it" real: the two places to enforce (CI + branch protection, a Claude Code Stop-hook), the exact wiring, and the protect-the-gate's-own-config caveat.
208
205
  - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), how the engine works: schemas, extract modes, the trust/noise model.
209
206
  - [CHANGELOG.md](CHANGELOG.md), what shipped in each version.
210
207
  - [CLAUDE.md](CLAUDE.md), the working notes for anyone (human or agent) changing this repo.
@@ -48,7 +48,7 @@ export interface MetricConfig {
48
48
  allowNonzero?: boolean;
49
49
  /** Per-command timeout, default 300. */
50
50
  timeoutSec?: number;
51
- /** `false` skips the source-only re-proof for this metric (tripwires whose gain legitimately lives in test files). */
51
+ /** `false` skips the source-only re-proof for this metric (tripwires whose gain legitimately lives in test files). Set on a metric that had the re-proof on, it applies from the next change, not the one that sets it. */
52
52
  gamingCheck?: boolean;
53
53
  /** Per-metric override of `Config.gamingThreshold`. */
54
54
  gamingThreshold?: number;
@@ -62,7 +62,7 @@ export interface MetricConfig {
62
62
  allowable?: boolean;
63
63
  }
64
64
 
65
- /** A named hard rule: any change touching one of `paths` fails the gate before any metric is read. */
65
+ /** A named hard rule: any change touching one of `paths` fails the gate, whatever the metrics read. */
66
66
  export interface Mandate {
67
67
  name: string;
68
68
  /** Globs: a bare name matches that basename at any depth, a pattern with `/` is anchored at the repo root, `**` crosses directories. */
@@ -85,9 +85,7 @@ export interface Config {
85
85
  env?: Record<string, string>;
86
86
  /** A per-ref build or install, run in the worktree before measuring (a failed setup leaves the metric inert, never a false green). */
87
87
  setup?: string;
88
- /** `false` never appends to `.promptwheel/outcomes.jsonl`. */
89
- record?: boolean;
90
- /** Fraction of a gain that must survive the source-only re-run; below it the win is `gamed`. Default 0.5. */
88
+ /** Fraction of a gain that must survive the source-only re-run; below it the win is `gamed`. Default 0.5. A change that lowers it is still judged by the value before it. */
91
89
  gamingThreshold?: number;
92
90
  /** Extra files that define the gate (a threshold, a budget); a change to one alongside a win is gamed by declaration. Same glob rules as `Mandate.paths`. */
93
91
  evaluatorPaths?: string[];
@@ -120,8 +118,6 @@ export interface CheckOptions {
120
118
  detectGaming?: boolean;
121
119
  /** A declared override reason (the same as `--allow` or a `Promptwheel-Allow:` commit trailer): a would-be `gamed`, or an `unprovable` win (its source-only re-run could not be applied), reads `pass`, recorded in `report.allowed`. */
122
120
  allow?: string;
123
- /** Append the run to `.promptwheel/outcomes.jsonl`. Default `false`. */
124
- record?: boolean;
125
121
  }
126
122
 
127
123
  // ---------------------------------------------------------------------------
@@ -148,6 +144,8 @@ export interface MetricResult extends Evaluation {
148
144
  name: string;
149
145
  direction: Direction;
150
146
  guard: boolean;
147
+ /** With `inert`: where the command ran and failed (`both` refs, or only `base` or `head`). Absent when it ran at neither: exit 126 or 127, a timeout, or the placeholder command `init` writes. */
148
+ ran?: 'both' | 'base' | 'head';
151
149
  /** `true` when the win did not survive the source-only re-run; `null` when the source-only patch could not be applied (then `unprovable` is set and the verdict is `inconclusive`, never `pass`). Absent without gaming detection or when the metric did not improve. */
152
150
  gamed?: boolean | null;
153
151
  /** `true`: the source-only patch could not be applied, so this win could not be re-proven and the verdict is `inconclusive`. `false` only after a declared override cleared it (then `allowed` is `true`). */
@@ -178,9 +176,13 @@ export interface MandateViolation {
178
176
 
179
177
  export interface GateWeakening {
180
178
  name: string;
181
- change: 'guard-removed' | 'guard-disabled';
182
- /** What the dropped guard would have read at head under its base definition. */
183
- status: MetricStatus;
179
+ /**
180
+ * `guard-edited`: the guard is still on but measured another way (a new `cmd`, `extract`, `direction`, `exact` or `report`).
181
+ * `measurement-edited`: the config's `setup`, `env` or linked dirs changed, and measured with the ones before the change the guard fails at head while the new ones read it fine.
182
+ */
183
+ change: 'guard-removed' | 'guard-disabled' | 'guard-edited' | 'measurement-edited';
184
+ /** What the guard would have read at head under its base definition (and the base `setup`, `env` and linked dirs); `failed` when it still fails there. */
185
+ status: MetricStatus | 'failed';
184
186
  }
185
187
 
186
188
  export interface EvaluatorChange {
@@ -192,11 +194,11 @@ export interface EvaluatorChange {
192
194
 
193
195
  /** The gate-integrity record, present when gaming detection ran. */
194
196
  export interface GateRecord {
195
- /** Truncated SHA-256 of the gate definition (metrics, guards, evaluatorPaths, gamingThreshold, mandates) at each ref; `base` is `null` when there was no config at base. */
197
+ /** Truncated SHA-256 of the gate definition (metrics, guards, evaluatorPaths, gamingThreshold, mandates, setup, env, linked dirs) at each ref; `base` is `null` when there was no config at base. */
196
198
  fingerprint: { base: string | null; head: string };
197
199
  /** The gate definition differs between base and head. */
198
200
  changed?: true;
199
- /** Guards the change dropped that would have failed at head; non-empty forces `gamed` unless allowed. */
201
+ /** Guards the change dropped, edited or measures another way that would have failed at head; non-empty forces `gamed` unless allowed. A change that adds the first config is compared with the zero-config gate (the detected test command). */
200
202
  weakened?: GateWeakening[];
201
203
  /** Declared `evaluatorPaths` files whose content changed in this diff. */
202
204
  evaluatorChanged?: EvaluatorChange[];
@@ -264,7 +266,7 @@ export declare function deriveVerdict(metrics: ReadonlyArray<Partial<MetricResul
264
266
  * a missing `extends` target, no commits yet, a base or head ref that is not in this clone, no
265
267
  * commit to compare against) throws an Error whose `name` is `'ConfigError'` and whose `exitCode`
266
268
  * is 64, the code the CLI exits with for the same condition. A `repo` that names a subdirectory
267
- * is resolved to the repository root, where the config lives and the record is written.
269
+ * is resolved to the repository root, where the config lives. A run writes nothing to the repo.
268
270
  */
269
271
  export declare function check(options?: CheckOptions): Report;
270
272