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 +22 -23
- package/README.md +25 -28
- package/bin/promptwheel.d.ts +15 -13
- package/bin/promptwheel.mjs +241 -311
- package/package.json +1 -1
- package/packs/supply-chain/README.md +16 -12
- package/packs/supply-chain/pack.json +1 -1
- package/packs/security/README.md +0 -62
- package/packs/security/pack.json +0 -12
- package/packs/security/patterns.json +0 -209
- package/packs/security/scan.mjs +0 -57
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`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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`;
|
|
107
|
-
throws
|
|
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 `
|
|
178
|
+
## 4. Self-check with `conformsToContract`
|
|
177
179
|
|
|
178
180
|
Prove your own check, loop, or CI step speaks the Contract.
|
|
179
181
|
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
promptwheel conformance report.json
|
|
183
|
-
cat report.json | promptwheel conformance -
|
|
182
|
+
```js
|
|
183
|
+
import { conformsToContract, verdictExitCode } from 'promptwheel';
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
27
|
-
#
|
|
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:
|
|
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
|
|
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 (`
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
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`,
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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.
|
|
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 `
|
|
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
|
|
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.
|
package/bin/promptwheel.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|