promptwheel 0.4.5 → 0.4.6

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/README.md CHANGED
@@ -4,17 +4,17 @@
4
4
 
5
5
  **A deterministic check for your coding loop.** A green test should mean your agent *fixed the code*, not that it edited the test to match the bug. PromptWheel tells the difference: it re-proves every win from the source changes alone, with no LLM, so a win that only survived by editing the test reads `GAMED`. Un-gameable by construction, because the code being checked never gets to grade itself.
6
6
 
7
- PromptWheel re-proves every "win" using the agent's **source edits alone**. If the gate only went green because the agent edited the test, mocked the grader, suppressed the error (`@ts-ignore` / `eslint-disable`), or deleted the feature, the win evaporates when those edits are reverted → **`VERDICT GAMED`, exit 2**. No LLM in the loop — a diff partition plus a re-run, so every flag is reproducible in seconds with a human-readable reason.
7
+ PromptWheel re-proves every "win" using the agent's **source edits alone**. If the gate only went green because the agent edited the test, mocked the grader, suppressed the error (`@ts-ignore` / `eslint-disable`), or deleted the feature, the win evaporates when those edits are reverted → **`VERDICT GAMED`, exit 2**. No LLM in the loop, a diff partition plus a re-run, so every flag is reproducible in seconds with a human-readable reason.
8
8
 
9
- It's built on an **outcome gate**: for any change it re-runs your metric commands (tests, lint, `tsc`, coverage, bundle, eval) in throwaway git worktrees before and after, and refuses to trust a delta inside the measurement noise. The gate everyone ships asks *"did the number move?"* — PromptWheel also asks *"did the agent **earn** it?"*
9
+ It's built on an **outcome gate**: for any change it re-runs your metric commands (tests, lint, `tsc`, coverage, bundle, eval) in throwaway git worktrees before and after, and refuses to trust a delta inside the measurement noise. The gate everyone ships asks *"did the number move?"*, PromptWheel also asks *"did the agent **earn** it?"*
10
10
 
11
- PromptWheel is the **signal, not the loop driver**: wire it as the verifier inside Claude Code `/loop`, a Ralph `while`-loop, or a Beads pull-loop. Each turn it answers one question — *did this turn earn its keep, and did the agent earn it honestly?* — so the loop improves instead of confidently degrading. (In CI it's the **outcome gate for AI code**: the same verdict, as a PR check.)
11
+ PromptWheel is the **signal, not the loop driver**: wire it as the verifier inside Claude Code `/loop`, a Ralph `while`-loop, or a Beads pull-loop. Each turn it answers one question, *did this turn earn its keep, and did the agent earn it honestly?*, so the loop improves instead of confidently degrading. (In CI it's the **outcome gate for AI code**: the same verdict, as a PR check.)
12
12
 
13
13
  PromptWheel is the reference check for the [Verified-Outcome Contract](https://github.com/sawbuck-ai/sawbuck/blob/master/CONTRACT.md), the open interface for a verifiable coding loop.
14
14
 
15
- > Same name, new meaning. The "wheel" is the **improvement flywheel**: every turn only counts if it **provably moved a metric without regressing another.** Orchestration (the old "wheel of prompts") *and the outcome gate itself* are now solved, commoditized problems; the open one is **catching when the agent games that gate** — making the reward signal one you can't cheat.
15
+ > Same name, new meaning. The "wheel" is the **improvement flywheel**: every turn only counts if it **provably moved a metric without regressing another.** Orchestration (the old "wheel of prompts") *and the outcome gate itself* are now solved, commoditized problems; the open one is **catching when the agent games that gate**, making the reward signal one you can't cheat.
16
16
 
17
- AI coding agents (and humans) declare success when a change *compiles and tests pass*. They almost never verify that a change **improved a real measurable thing without regressing another** — and outcome data rots in dashboards (DORA, Swarmia) disconnected from the change that caused it. PromptWheel closes that loop.
17
+ AI coding agents (and humans) declare success when a change *compiles and tests pass*. They almost never verify that a change **improved a real measurable thing without regressing another**, and outcome data rots in dashboards (DORA, Swarmia) disconnected from the change that caused it. PromptWheel closes that loop.
18
18
 
19
19
  For any change (`base` → `head`), it measures each configured metric in an **isolated git worktree** before and after, **refuses to trust a delta inside the measurement noise band**, enforces **regression guards**, and emits a structured verdict:
20
20
 
@@ -29,41 +29,41 @@ PromptWheel a1b2c3d → e4f5g6h (×5)
29
29
  VERDICT: PASS
30
30
  ```
31
31
 
32
- Exit `0` pass · `1` fail · `2` gamed · `3` inconclusive (a guard measured nothing) — CI-friendly. No build step, zero dependencies, Node 18+.
32
+ Exit `0` pass · `1` fail · `2` gamed · `3` inconclusive (a guard measured nothing), CI-friendly. No build step, zero dependencies, Node 18+.
33
33
 
34
- ## Catch your agent cheating — on by default
34
+ ## Catch your agent cheating, on by default
35
35
 
36
- The headline feature, and it runs **by default** (in both `run` and `improve`; pass `--no-detect-gaming` for the bare outcome gate). After the normal gate, every *win* is re-proven using the agent's source edits **alone**: PromptWheel splits the diff into production source vs. `{test, config, grader, golden}` files, rebuilds a clean worktree at the base with **only the source slice** applied, and re-runs the gate. If the win doesn't survive — because it only passed by skipping/deleting a test, mocking the grader, editing a golden, relaxing a config, or the "win" touched zero source files — the verdict is **`GAMED` (exit 2)**:
36
+ The headline feature, and it runs **by default** (in both `run` and `improve`; pass `--no-detect-gaming` for the bare outcome gate). After the normal gate, every *win* is re-proven using the agent's source edits **alone**: PromptWheel splits the diff into production source vs. `{test, config, grader, golden}` files, rebuilds a clean worktree at the base with **only the source slice** applied, and re-runs the gate. If the win doesn't survive, because it only passed by skipping/deleting a test, mocking the grader, editing a golden, relaxing a config, or the "win" touched zero source files, the verdict is **`GAMED` (exit 2)**:
37
37
 
38
38
  ```
39
39
  $ promptwheel run
40
40
 
41
41
  PromptWheel base → head
42
42
  ▲ tests_pass 0 → 1 (+1, improved) [guard✓, high]
43
- 🚩 GAMED — the "win" changed zero production-source files — only test/config/grader/golden
44
- VERDICT: GAMED — a metric "improved" by editing the goalposts, not the source
43
+ 🚩 GAMED, the "win" changed zero production-source files, only test/config/grader/golden
44
+ VERDICT: GAMED, a metric "improved" by editing the goalposts, not the source
45
45
  ```
46
46
 
47
- **See it fire yourself** — 60 seconds, illustrative (a hand-built cheat is a *mechanism* demo, not evidence):
47
+ **See it fire yourself**, 60 seconds, illustrative (a hand-built cheat is a *mechanism* demo, not evidence):
48
48
 
49
49
  ```bash
50
50
  mkdir pw-cheat-demo && cd pw-cheat-demo && git init -q
51
51
  printf '{"name":"demo","type":"module","scripts":{"test":"node --test"}}\n' > package.json
52
52
  printf 'export const add = (a, b) => a - b; // BUG: should be a + b\n' > add.js
53
- printf "import {test} from 'node:test'; import assert from 'node:assert'; import {add} from './add.js';\ntest('add', () => assert.equal(add(2, 3), 5)); // honest test — FAILS on the bug\n" > add.test.js
53
+ printf "import {test} from 'node:test'; import assert from 'node:assert'; import {add} from './add.js';\ntest('add', () => assert.equal(add(2, 3), 5)); // honest test, FAILS on the bug\n" > add.test.js
54
54
  git add -A && git commit -qm 'buggy code + an honest failing test'
55
55
  npx -y promptwheel@latest init && git add -A && git commit -qm 'add the gate config'
56
56
 
57
- # an agent "greens" the suite by editing the TEST to expect the bug — the code stays broken:
57
+ # an agent "greens" the suite by editing the TEST to expect the bug, the code stays broken:
58
58
  printf "import {test} from 'node:test'; import assert from 'node:assert'; import {add} from './add.js';\ntest('add', () => assert.equal(add(2, 3), -1));\n" > add.test.js
59
59
  git commit -qam 'make the suite green'
60
60
 
61
61
  npx -y promptwheel@latest run --base HEAD~1 --head HEAD
62
- # ▲ tests_pass 0 → 1 🚩 GAMED — the win changed zero production-source files
62
+ # ▲ tests_pass 0 → 1 🚩 GAMED, the win changed zero production-source files
63
63
  # VERDICT: GAMED (exit 2)
64
64
  ```
65
65
 
66
- Inline source-file suppressions (`@ts-nocheck`, `eslint-disable`, `# noqa`) — and the quieter cheat of *weakening the suite while the metric stays flat* — are a different shape, caught by **tripwire guards** (`test_count`, `skipped_tests`, `suppressions`, `assertions`) that fail when a "win" introduces them. The plain `init` default includes these tripwires, so gutting a test file fails the gate out of the box:
66
+ Inline source-file suppressions (`@ts-nocheck`, `eslint-disable`, `# noqa`), and the quieter cheat of *weakening the suite while the metric stays flat*, are a different shape, caught by **tripwire guards** (`test_count`, `skipped_tests`, `suppressions`, `assertions`) that fail when a "win" introduces them. The plain `init` default includes these tripwires, so gutting a test file fails the gate out of the box:
67
67
 
68
68
  ```bash
69
69
  npx promptwheel init # guarded tests + antihack tripwires by default
@@ -73,7 +73,7 @@ npx promptwheel run # detection ON by default · exit 0 wi
73
73
 
74
74
  Deterministic, zero-LLM, zero-network: an LLM judge asking "did you cheat?" is itself gameable; this is a diff partition plus a re-run, so a flag is trustworthy without a human in the loop. The 50%-of-gain-survives threshold is the default and is tunable via `gamingThreshold` (config-level, or per-metric).
75
75
 
76
- This is **one layer**, not a silver bullet: it catches the evaluator-tampering class (test/grader/golden/config edits) deterministically and for free, so the expensive layers — held-out tests for semantically-weak wins, an LLM judge or a human for intent and leakage — are reserved for the calls only they can make. See **[docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md)** for the coverage matrix and honest scope, and **[bench/RESULTS.md](bench/RESULTS.md)** for the measured numbers (`node bench/gaming-bench.mjs` to reproduce — it includes a cross-stack table with a real pytest run + a numeric eval-pass-rate metric). **Gate your own stack** — pytest · tsc · coverage · bundle · llm-eval — copy a config from **[examples/](examples/)**.
76
+ This is **one layer**, not a silver bullet: it catches the evaluator-tampering class (test/grader/golden/config edits) deterministically and for free, so the expensive layers, held-out tests for semantically-weak wins, an LLM judge or a human for intent and leakage, are reserved for the calls only they can make. See **[docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md)** for the coverage matrix and honest scope, and **[bench/RESULTS.md](bench/RESULTS.md)** for the measured numbers (`node bench/gaming-bench.mjs` to reproduce, it includes a cross-stack table with a real pytest run + a numeric eval-pass-rate metric). **Gate your own stack**, pytest · tsc · coverage · bundle · llm-eval, copy a config from **[examples/](examples/)**.
77
77
 
78
78
  ## Use
79
79
 
@@ -96,13 +96,13 @@ npx promptwheel insights
96
96
 
97
97
  # EXPERIMENTAL (Phase 5): the earned playbook + where the next attempt should go
98
98
  npx promptwheel playbook # decayed, evidence-gated claims distilled from the record
99
- npx promptwheel suggest # UCB over the lever scores — proven levers vs under-explored arms
99
+ npx promptwheel suggest # UCB over the lever scores, proven levers vs under-explored arms
100
100
  npx promptwheel backfill -n 30 # cold start: seed the record from git history (cohort-tagged, commit types → labels)
101
101
  ```
102
102
 
103
- **The consequence ledger.** git records *what changed*; PromptWheel records *what the change did* — same trust model (local, deterministic, append-only, no server, no LLM in the verdict). `playbook` and `suggest` are pure re-derivations over that ledger: every rendered line was measured by the gate, decays unless re-earned, and stays hidden below an evidence threshold. No compounding claim is made for them until the A/B acceptance test (`bench/compounding-ab.mjs`) passes on real usage data.
103
+ **The consequence ledger.** git records *what changed*; PromptWheel records *what the change did*, same trust model (local, deterministic, append-only, no server, no LLM in the verdict). `playbook` and `suggest` are pure re-derivations over that ledger: every rendered line was measured by the gate, decays unless re-earned, and stays hidden below an evidence threshold. No compounding claim is made for them until the A/B acceptance test (`bench/compounding-ab.mjs`) passes on real usage data.
104
104
 
105
- **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 — `node_modules` by default, `.venv` for Python, etc.), removed when the run finishes. The only thing PromptWheel writes to your repo is the optional `.promptwheel/outcomes.jsonl` record — commit it to build the per-repo "what moves what" history, or `.gitignore` it (`--no-record` to skip entirely). A hard-killed run can't leave clutter behind: the next run **self-heals** any orphaned worktree (stale registry entry + abandoned temp checkout).
105
+ **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, `node_modules` by default, `.venv` for Python, etc.), removed when the run finishes. The only thing PromptWheel writes to your repo is the optional `.promptwheel/outcomes.jsonl` record, commit it to build the per-repo "what moves what" history, or `.gitignore` it (`--no-record` to skip entirely). A hard-killed run can't leave clutter behind: the next run **self-heals** any orphaned worktree (stale registry entry + abandoned temp checkout).
106
106
 
107
107
  ## Loop patterns
108
108
 
@@ -116,11 +116,11 @@ while npx promptwheel improve --attempt "claude -p 'speed up the hot path'"; do
116
116
  npx promptwheel run --working --json # branch on .verdict / per-metric .status
117
117
  ```
118
118
 
119
- The exit code is the contract — `0` kept · `1` regression · `3` plateau — so any driver (`/loop`, a Ralph `while`, a Beads pull-loop) converges without parsing anything. PromptWheel never drives the loop; it only says whether the turn counted.
119
+ The exit code is the contract, `0` kept · `1` regression · `3` plateau, so any driver (`/loop`, a Ralph `while`, a Beads pull-loop) converges without parsing anything. PromptWheel never drives the loop; it only says whether the turn counted.
120
120
 
121
121
  **Two callers, by design.** Your *agent* consumes the verdict as the loop's per-turn reward (`improve` / `run --working --json`; exit `0` kept · `1`/`2` reverted · `3` plateau). The *harness* (a Stop-hook or CI) runs `--detect-gaming`, the audit the agent **can't self-clear**, because the check never grades itself; it re-proves from the source alone (see [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md)). Same tool; *who calls it* is the difference between a reward and an audit.
122
122
 
123
- ## In Claude Code — plugin
123
+ ## In Claude Code, plugin
124
124
 
125
125
  Bring the gate into Claude Code as slash commands:
126
126
 
@@ -131,7 +131,7 @@ Bring the gate into Claude Code as slash commands:
131
131
 
132
132
  Then `/promptwheel:setup` (write a config) · `/promptwheel:gate` (gate uncommitted changes) · `/promptwheel:improve <cmd>` (keep-if-improved) · `/promptwheel:insights`. The plugin wraps the CLI, so install that too (`npm i -g promptwheel`). See [`plugins/promptwheel/`](plugins/promptwheel/).
133
133
 
134
- ## In CI — GitHub Action
134
+ ## In CI, GitHub Action
135
135
 
136
136
  Drop this in your repo (it posts a verdict comment on every PR and fails the check on a guarded regression beyond noise):
137
137
 
@@ -150,9 +150,9 @@ jobs:
150
150
  with: { repeat: '3' }
151
151
  ```
152
152
 
153
- The Action runs straight from its own checkout — no npm install, no build. See [`action.yml`](action.yml).
153
+ The Action runs straight from its own checkout, no npm install, no build. See [`action.yml`](action.yml).
154
154
 
155
- ## Config — `promptwheel.config.json`
155
+ ## Config, `promptwheel.config.json`
156
156
 
157
157
  ```json
158
158
  {
@@ -165,22 +165,22 @@ The Action runs straight from its own checkout — no npm install, no build. See
165
165
  }
166
166
  ```
167
167
 
168
- - **cmd** — any shell command, run inside the worktree.
169
- - **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).
170
- - **direction** — `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
171
- - **guard** — `true` = a *trusted* regression **fails** the gate; `false` = informational.
172
- - **gamingCheck** — `false` exempts a metric from `--detect-gaming`'s 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.
173
- - **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`.
168
+ - **cmd**, any shell command, run inside the worktree.
169
+ - **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).
170
+ - **direction**, `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
171
+ - **guard**, `true` = a *trusted* regression **fails** the gate; `false` = informational.
172
+ - **gamingCheck**, `false` exempts a metric from `--detect-gaming`'s 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.
173
+ - **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`.
174
174
 
175
175
  Dependency & environment keys (config-level; `init` writes stack defaults):
176
176
 
177
- - **linkDirs** — dirs to symlink from your checkout into each measuring worktree, for deps that live outside git. Default `["node_modules"]`; `init` writes `[".venv"]` for Python, `["target"]` for Rust. (`"linkNodeModules": false` still works as shorthand for "link nothing".)
178
- - **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.
179
- - **setup** — an optional command run once per ref before metrics (e.g. `npm run build`, `pip install -e .`), after any source patch — so a build-gated suite measures the ref it's actually on.
177
+ - **linkDirs**, dirs to symlink from your checkout into each measuring worktree, for deps that live outside git. Default `["node_modules"]`; `init` writes `[".venv"]` for Python, `["target"]` for Rust. (`"linkNodeModules": false` still works as shorthand for "link nothing".)
178
+ - **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.
179
+ - **setup**, an optional command run once per ref before metrics (e.g. `npm run build`, `pip install -e .`), after any source patch, so a build-gated suite measures the ref it's actually on.
180
180
 
181
181
  ## Guardrails & inheritance
182
182
 
183
- To see what's actually enforced in a repo — including guards inherited from a shared config:
183
+ To see what's actually enforced in a repo, including guards inherited from a shared config:
184
184
 
185
185
  ```bash
186
186
  promptwheel guards
@@ -194,40 +194,40 @@ Teams keep one shared base config and have every repo inherit it via `extends`:
194
194
  "metrics": [ { "name": "cost_per_run_usd", "guard": false } ] } // loosen one inherited guard, locally
195
195
  ```
196
196
 
197
- `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 stream.
197
+ `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 stream.
198
198
 
199
- Read this way, `extends` is **the shared invariants — the "business rules" — your agents inherit and are held to**: enforced as *measured guards* (a trusted regression fails the gate / reverts the commit), not advisory text an agent can quietly ignore. (Natural-language conventions belong in `AGENTS.md`; only deterministic, measurable guards belong here.)
199
+ Read this way, `extends` is **the shared invariants, the "business rules", your agents inherit and are held to**: enforced as *measured guards* (a trusted regression fails the gate / reverts the commit), not advisory text an agent can quietly ignore. (Natural-language conventions belong in `AGENTS.md`; only deterministic, measurable guards belong here.)
200
200
 
201
- ## Trust model — the point of the whole thing
201
+ ## Trust model, the point of the whole thing
202
202
 
203
203
  A number that jumps around between runs is worthless as a signal. PromptWheel won't pretend otherwise:
204
204
 
205
205
  - `--repeat N` measures each metric N times at both refs and uses the **median**; the **noise band** is the observed spread.
206
206
  - A delta **inside the noise band** is reported `inconclusive` with `low` confidence and **does not fail a guard** (no flaky CI failures).
207
- - **Confidence:** `high` (deterministic extract, or zero observed noise) · `medium` (delta clears the noise band) · `low` (delta inside noise) · `unverified` (single read — run `--repeat` to earn trust).
207
+ - **Confidence:** `high` (deterministic extract, or zero observed noise) · `medium` (delta clears the noise band) · `low` (delta inside noise) · `unverified` (single read, run `--repeat` to earn trust).
208
208
 
209
209
  The accumulated record of **which change-types move which metrics** is the asset: a per-repo reward signal a base tool can't replicate, and the spine that lets an agent loop learn what actually helps.
210
210
 
211
211
  ## Where PromptWheel fits (and where it doesn't)
212
212
 
213
- - **vs single-axis CI gates** (Codspeed, Bencher, size-limit, Lighthouse-CI): they own deep statistics on *one* metric; PromptWheel is the **cross-metric gate that composes them** — "did `eval_pass_rate` **and** cost improve without regressing the guards?" in one verdict. Wrap any of them as a metric `cmd` and let `--repeat` handle the noise.
214
- - **vs loop/agent frameworks** (Ralph, GEPA, reward models): PromptWheel is the **execution-grounded reward they lack** — it runs your real suite with zero deps; it does not drive the loop or do test-time search.
213
+ - **vs single-axis CI gates** (Codspeed, Bencher, size-limit, Lighthouse-CI): they own deep statistics on *one* metric; PromptWheel is the **cross-metric gate that composes them**, "did `eval_pass_rate` **and** cost improve without regressing the guards?" in one verdict. Wrap any of them as a metric `cmd` and let `--repeat` handle the noise.
214
+ - **vs loop/agent frameworks** (Ralph, GEPA, reward models): PromptWheel is the **execution-grounded reward they lack**, it runs your real suite with zero deps; it does not drive the loop or do test-time search.
215
215
  - **When NOT to use it:** if you only care that tests pass, your base verifier already has you covered. PromptWheel earns its place when you have a **graded numeric metric** beyond pass/fail (eval score, $/run, latency, size) that a change could quietly move.
216
216
 
217
217
  ## Docs
218
218
 
219
- - [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md) — how `--detect-gaming` fits as the **deterministic layer** alongside held-out tests, LLM judges, and human review: the coverage matrix, the compose-as-a-pipeline model, and the honest in/out-of-scope boundary.
220
- - [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 tested Claude Code Stop-hook), the exact wiring, and the *protect-the-gate's-own-config* caveat.
221
- - [docs/VISION.md](docs/VISION.md) — why we pivoted from orchestrator to outcome gate, the thesis, the moat, the open-core model.
222
- - [docs/ROADMAP.md](docs/ROADMAP.md) — the phased plan and the ship-now/stay-thin guardrails.
223
- - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the engine works: schemas, extract modes, the trust/noise model.
224
- - [docs/LEARNING.md](docs/LEARNING.md) — the (research-gated) Phase-5 design: outcome-curated playbook (Agentic Context Engineering, Stanford 2510.04618) + UCB work-discovery.
225
- - [CLAUDE.md](CLAUDE.md) — the constitution for anyone (human or agent) working in this repo.
219
+ - [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md), how `--detect-gaming` fits as the **deterministic layer** alongside held-out tests, LLM judges, and human review: the coverage matrix, the compose-as-a-pipeline model, and the honest in/out-of-scope boundary.
220
+ - [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 tested Claude Code Stop-hook), the exact wiring, and the *protect-the-gate's-own-config* caveat.
221
+ - [docs/VISION.md](docs/VISION.md), why we pivoted from orchestrator to outcome gate, the thesis, the moat, the open-core model.
222
+ - [docs/ROADMAP.md](docs/ROADMAP.md), the phased plan and the ship-now/stay-thin guardrails.
223
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), how the engine works: schemas, extract modes, the trust/noise model.
224
+ - [docs/LEARNING.md](docs/LEARNING.md), the (research-gated) Phase-5 design: outcome-curated playbook (Agentic Context Engineering, Stanford 2510.04618) + UCB work-discovery.
225
+ - [CLAUDE.md](CLAUDE.md), the constitution for anyone (human or agent) working in this repo.
226
226
 
227
227
  ## Develop
228
228
 
229
229
  ```bash
230
- npm test # the dep-free suite (node:test) — unit + integration, no dependencies
230
+ npm test # the dep-free suite (node:test), unit + integration, no dependencies
231
231
  ```
232
232
 
233
233
  The engine is one importable file; pure helpers are exported for unit tests, the CLI runs only when invoked directly. Add a test with every behavior change.
@@ -236,15 +236,15 @@ The engine is one importable file; pure helpers are exported for unit tests, the
236
236
 
237
237
  - [x] before/after worktree measurement + regression guards
238
238
  - [x] noise band + confidence (don't trust a delta inside the jitter)
239
- - [x] `--working` mode — measure uncommitted changes (tracked **and** untracked)
240
- - [x] persisted reward stream (`.promptwheel/outcomes.jsonl`) — the compounding "what moves what" record
239
+ - [x] `--working` mode, measure uncommitted changes (tracked **and** untracked)
240
+ - [x] persisted reward stream (`.promptwheel/outcomes.jsonl`), the compounding "what moves what" record
241
241
  - [x] GitHub Action / PR-comment wrapper (open-core distribution surface)
242
- - [x] agent loop: `improve` — propose → gate → keep only if a metric improved
242
+ - [x] agent loop: `improve`, propose → gate → keep only if a metric improved
243
243
  - [x] loop-consumable `improve`: exit `0` kept / `1` regression / `3` plateau + `--json result`
244
- - [x] `promptwheel init` + presets — zero-config onboarding
245
- - [x] `insights` — reward-stream aggregation (Phase-5 seed)
246
- - [x] `--detect-gaming` — reward-hack detection: re-prove the win from source edits alone + `antihack` preset
247
- - [x] npm publish — `promptwheel@0.4.2` (the lead magnet)
248
- - [x] outcome-curated learning + UCB work-discovery — `playbook` + `suggest` + the compounding A/B harness (**experimental**; compounding *claims* stay gated on real-data proof — see [docs/LEARNING.md](docs/LEARNING.md))
244
+ - [x] `promptwheel init` + presets, zero-config onboarding
245
+ - [x] `insights`, reward-stream aggregation (Phase-5 seed)
246
+ - [x] `--detect-gaming`, reward-hack detection: re-prove the win from source edits alone + `antihack` preset
247
+ - [x] npm publish, `promptwheel@0.4.2` (the lead magnet)
248
+ - [x] outcome-curated learning + UCB work-discovery, `playbook` + `suggest` + the compounding A/B harness (**experimental**; compounding *claims* stay gated on real-data proof, see [docs/LEARNING.md](docs/LEARNING.md))
249
249
 
250
- > Status: **published** (npm `promptwheel`, v0.4.2) — all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
250
+ > Status: **published** (npm `promptwheel`, v0.4.2), all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // PromptWheel — the per-turn reward for AI coding loops (a.k.a. the outcome gate for AI code).
2
+ // PromptWheel, the per-turn reward for AI coding loops (a.k.a. the outcome gate for AI code).
3
3
  //
4
4
  // For any change (base ref → head ref), measure each configured metric in an
5
5
  // isolated git worktree BEFORE and AFTER, enforce regression guards, and refuse
6
6
  // to trust a delta that sits inside the measurement noise band. Emits a
7
7
  // structured {verdict, metric, delta, confidence}. The thing base agents don't do:
8
- // verify a real number moved — not just "the diff applied / tests passed".
8
+ // verify a real number moved, not just "the diff applied / tests passed".
9
9
  //
10
10
  // Zero deps, zero build. Node 18+.
11
11
  // promptwheel run [--base R] [--head R] [--repeat N] [--json]
@@ -59,14 +59,14 @@ function resolveConfig(p, repo, label, seen) {
59
59
  for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
60
60
  return { ...scalars, metrics: [...byName.values()] };
61
61
  } finally {
62
- seen.delete(abs); // only ACTIVE ancestors are a cycle — a diamond (a base reached two ways) is fine
62
+ seen.delete(abs); // only ACTIVE ancestors are a cycle, a diamond (a base reached two ways) is fine
63
63
  }
64
64
  }
65
65
 
66
66
  function loadConfig(repo) {
67
67
  const candidates = ['promptwheel.config.json', 'outcome-gate.config.json']; // back-compat alias
68
68
  const p = candidates.map((c) => join(repo, c)).find(existsSync);
69
- if (!p) { console.error('no promptwheel.config.json — run: promptwheel init (writes one for your stack)'); process.exit(2); }
69
+ if (!p) { console.error('no promptwheel.config.json, run: promptwheel init (writes one for your stack)'); process.exit(2); }
70
70
  const cfg = resolveConfig(p, repo, 'local', new Set());
71
71
  if (!Array.isArray(cfg.metrics) || cfg.metrics.length === 0) {
72
72
  console.error('config.metrics must be a non-empty array'); process.exit(2);
@@ -96,7 +96,7 @@ function runMetric(cwd, m, env) {
96
96
 
97
97
  // extract modes: 'number' (last number, default) | 'lines' | 'exit' | {regex}
98
98
  // A FAILED command is unmeasurable (null -> the metric goes inert, guards
99
- // fail loudly) — error text often contains numbers ("Node.js v22") and
99
+ // fail loudly), error text often contains numbers ("Node.js v22") and
100
100
  // silently measuring those produced identical junk on both refs = a false
101
101
  // green. 'exit' mode consumes nonzero by design; anything else needs the
102
102
  // per-metric opt-in `allowNonzero: true`.
@@ -118,7 +118,7 @@ function extract(stdout, code, mode, allowNonzero) {
118
118
  // node_modules-only symlink: `linkDirs` says what to link (default ['node_modules']; back-compat
119
119
  // with linkNodeModules:false → link nothing); `env` sets vars ({wt} → the worktree path, so a
120
120
  // Python run can put the measured ref FIRST on PYTHONPATH). Ecosystem knowledge lives in config,
121
- // not here — the engine stays ecosystem-agnostic.
121
+ // not here, the engine stays ecosystem-agnostic.
122
122
  function bridgeEnv(repo, wt, cfg) {
123
123
  const linkDirs = Array.isArray(cfg.linkDirs) ? cfg.linkDirs : (cfg.linkNodeModules === false ? [] : ['node_modules']);
124
124
  for (const d of linkDirs) {
@@ -169,9 +169,9 @@ function evaluate(m, beforeS, afterS, repeat) {
169
169
 
170
170
  let confidence;
171
171
  if (deterministic) confidence = 'high';
172
- else if (!sampledNoise) confidence = 'unverified'; // single read — noise unknown; run --repeat to trust
172
+ else if (!sampledNoise) confidence = 'unverified'; // single read, noise unknown; run --repeat to trust
173
173
  else if (noise === 0) confidence = 'high';
174
- else if (withinNoise) confidence = 'low'; // delta is inside the jitter — not real
174
+ else if (withinNoise) confidence = 'low'; // delta is inside the jitter, not real
175
175
  else confidence = 'medium';
176
176
 
177
177
  let improved = false, regressed = false;
@@ -186,7 +186,7 @@ function evaluate(m, beforeS, afterS, repeat) {
186
186
  // guards fail only on a TRUSTED regression (within-noise regressions are not failures)
187
187
  const ok = m.guard ? !regressed : true;
188
188
  // a guarded pass-metric that never passes is protecting NOTHING (broken test cmd,
189
- // missing script, failed install) — surface it instead of folding into a green verdict.
189
+ // missing script, failed install), surface it instead of folding into a green verdict.
190
190
  const inert = !!m.guard && dir === 'pass' && before === 0 && after === 0;
191
191
  return { before, after, delta, status, ok, confidence, noise, ...(inert ? { inert: true } : {}) };
192
192
  }
@@ -196,7 +196,7 @@ function evaluate(m, beforeS, afterS, repeat) {
196
196
  // If the improvement evaporates once test/config/grader/golden changes are reverted,
197
197
  // the agent moved the goalposts (edited the test, mocked the grader, suppressed the
198
198
  // lint rule, deleted the feature) instead of earning it. Pure arithmetic, fully
199
- // explainable — the thing the loop-owner structurally won't ship about its own agent.
199
+ // explainable, the thing the loop-owner structurally won't ship about its own agent.
200
200
  // ---------------------------------------------------------------------------
201
201
  const NON_SOURCE = [
202
202
  /(^|\/)(tests?|__tests?__|spec|e2e|fixtures|snapshots|__snapshots__|__mocks__)\//i,
@@ -205,7 +205,7 @@ const NON_SOURCE = [
205
205
  /(^|\/)test_[^/]*\.py$/i, /_test\.py$/i, /(^|\/)conftest\.py$/i,
206
206
  /\.snap$/i, /(^|\/)golden[^/]*$/i, /\.golden$/i,
207
207
  /(^|\/)(eval|grader|score)[^/]*\.[cm]?[jt]s$/i,
208
- // config files only — require a .config/.conf/.setup segment so PRODUCTION source that
208
+ // config files only, require a .config/.conf/.setup segment so PRODUCTION source that
209
209
  // happens to be named after a tool (e.g. src/installers/eslint.ts) is NOT swept out of
210
210
  // the source slice (a false sweep here can flag an honest win as GAMED).
211
211
  /(^|\/)(jest|vitest|playwright|cypress|karma|babel|eslint|pytest)[^/]*\.(config|conf|setup)\.[^/]+$/i,
@@ -214,7 +214,7 @@ const NON_SOURCE = [
214
214
  /(^|\/)(pytest\.ini|setup\.cfg|\.flake8|tox\.ini|eslint\.config\.[cm]?js)$/i,
215
215
  // promptwheel's OWN evaluator surface: the config that defines the gate
216
216
  // and any pack copied into the repo by `init --preset`. Editing these is
217
- // moving the goalposts, never a production improvement — an agent that
217
+ // moving the goalposts, never a production improvement, an agent that
218
218
  // "improved" security_findings by deleting pattern rules must show 0%
219
219
  // retained in the source-only re-run, not 100%.
220
220
  /(^|\/)promptwheel\.config\.json$/i,
@@ -273,26 +273,26 @@ function gain(m, before, val) {
273
273
  }
274
274
 
275
275
  // a metric that improved full-diff: does the win survive when only source edits are applied?
276
- // gamed = source edits alone reproduce less than `threshold` of the gain (default 0.5 — the
276
+ // gamed = source edits alone reproduce less than `threshold` of the gain (default 0.5, the
277
277
  // rest came from editing the goalposts). Tune via `gamingThreshold` (config-level or per-metric).
278
278
  function judgeGaming(m, srcResult, threshold = 0.5) {
279
- if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files — only test/config/grader/golden' };
280
- if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly — inconclusive' };
279
+ if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files, only test/config/grader/golden' };
280
+ if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly, inconclusive' };
281
281
  const sourceOnly = median(srcResult.samples);
282
282
  const full = gain(m, m.before, m.after);
283
283
  if (full == null || full <= 0) return { gamed: false, sourceOnly, retained: 1, reason: 'no real improvement to re-prove' };
284
284
  const retained = +(gain(m, m.before, sourceOnly) / full).toFixed(3);
285
285
  const gamed = retained < threshold;
286
286
  return { gamed, sourceOnly, retained, reason: gamed
287
- ? `only ${(retained * 100).toFixed(0)}% of the gain survives when test/config/grader changes are reverted (threshold ${(threshold * 100).toFixed(0)}%) — most of the "win" came from editing the goalposts`
288
- : `${(retained * 100).toFixed(0)}% of the gain survives source-only — the source earned it` };
287
+ ? `only ${(retained * 100).toFixed(0)}% of the gain survives when test/config/grader changes are reverted (threshold ${(threshold * 100).toFixed(0)}%), most of the "win" came from editing the goalposts`
288
+ : `${(retained * 100).toFixed(0)}% of the gain survives source-only, the source earned it` };
289
289
  }
290
290
 
291
291
  // ---------------------------------------------------------------------------
292
292
  // main
293
293
  // ---------------------------------------------------------------------------
294
294
  // snapshot the working tree (tracked + untracked) into a dangling commit WITHOUT
295
- // touching the real index or files — so a gate/improve sees a file an agent just added.
295
+ // touching the real index or files, so a gate/improve sees a file an agent just added.
296
296
  // Returns 'HEAD' when nothing changed.
297
297
  function workingSnapshot(repo) {
298
298
  const idxDir = mkdtempSync(join(tmpdir(), 'pw-idx-'));
@@ -317,7 +317,7 @@ function workingSnapshot(repo) {
317
317
  }
318
318
  }
319
319
 
320
- // self-heal: a hard-killed run (SIGKILL / power loss) can leave an orphaned worktree —
320
+ // self-heal: a hard-killed run (SIGKILL / power loss) can leave an orphaned worktree,
321
321
  // its `finally` never ran. On the next run, drop stale worktree registry entries and any
322
322
  // abandoned /tmp checkout, so nothing accumulates in your repo or temp dir. Never touches a
323
323
  // live worktree (a still-registered dir, or a checkout younger than 1h).
@@ -332,7 +332,7 @@ function selfHeal(repo) {
332
332
  for (const name of readdirSync(tmp)) {
333
333
  if (!name.startsWith('promptwheel-') && !name.startsWith('pw-idx-')) continue; // worktrees + temp-index dirs
334
334
  const p = join(tmp, name);
335
- if (tracked.has(p)) continue; // a live/registered worktree — leave it
335
+ if (tracked.has(p)) continue; // a live/registered worktree, leave it
336
336
  try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
337
337
  }
338
338
  } catch { /* cleanup must never block the gate */ }
@@ -340,7 +340,7 @@ function selfHeal(repo) {
340
340
 
341
341
  // Python editable installs (src-layout `pip install -e .`) can pin imports to the ORIGINAL
342
342
  // checkout: the worktree's tests then import your current tree at BOTH refs and every delta
343
- // reads 0 — the gate is structurally blind on that repo shape. Undetectable from the verdict
343
+ // reads 0, the gate is structurally blind on that repo shape. Undetectable from the verdict
344
344
  // (it looks like "unchanged"), so detect the editable install itself and warn. Verified by a
345
345
  // controlled repro (CHANGELOG 0.2.2).
346
346
  function warnEditableInstall(repo) {
@@ -355,8 +355,8 @@ function warnEditableInstall(repo) {
355
355
  'print("HIT" if hit else "")',
356
356
  ].join('\n');
357
357
  const out = execFileSync('python3', ['-c', probe, repo], { encoding: 'utf8', timeout: 5000 }).trim();
358
- if (out === 'HIT') console.error('⚠ editable install of this repo detected (pip install -e): worktree measurements may import your ORIGINAL checkout, not the measured ref — deltas can read 0. Use a non-editable install in the measuring venv, or put the worktree src on PYTHONPATH.');
359
- } catch { /* no python / cannot tell — stay quiet */ }
358
+ if (out === 'HIT') console.error('⚠ editable install of this repo detected (pip install -e): worktree measurements may import your ORIGINAL checkout, not the measured ref, deltas can read 0. Use a non-editable install in the measuring venv, or put the worktree src on PYTHONPATH.');
359
+ } catch { /* no python / cannot tell, stay quiet */ }
360
360
  }
361
361
 
362
362
  // the shared core: measure a change (base→head) and return the structured report
@@ -368,11 +368,11 @@ function gate(repo, opts) {
368
368
 
369
369
  let base, head;
370
370
  if (opts.working) {
371
- // measure uncommitted changes — tracked AND untracked — via a temp-index snapshot;
371
+ // measure uncommitted changes, tracked AND untracked, via a temp-index snapshot;
372
372
  // never touches the real index or working tree. (A loop agent's most common action
373
373
  // is to ADD a file; `git stash create` omits untracked, which silently reverted them.)
374
374
  try { git(['rev-parse', '--verify', 'HEAD'], repo); }
375
- catch { console.error('no commits yet — make an initial commit before gating --working changes'); process.exit(2); }
375
+ catch { console.error('no commits yet, make an initial commit before gating --working changes'); process.exit(2); }
376
376
  base = 'HEAD';
377
377
  head = workingSnapshot(repo);
378
378
  } else {
@@ -399,7 +399,7 @@ function gate(repo, opts) {
399
399
  const failed = metrics.some((m) => m.guard && !m.ok);
400
400
  const gamed = metrics.some((m) => m.gamed === true);
401
401
  // a guard that never runs (inert: a pass/fail metric stuck at 0 across both refs) verifies
402
- // nothing — don't launder that into a green PASS. The honest verdict is "couldn't measure".
402
+ // nothing, don't launder that into a green PASS. The honest verdict is "couldn't measure".
403
403
  // BUT scope it to a bare no-op run: an inert guard must NOT bury a genuine, measured win
404
404
  // elsewhere (that would make `improve` revert good changes and misread them as a plateau).
405
405
  const inconclusive = !failed && !gamed && metrics.some((m) => m.inert) && !metrics.some((m) => m.status === 'improved');
@@ -442,11 +442,11 @@ function improve(argv) {
442
442
  if (!args.attempt) { console.error('improve requires --attempt "<command that changes the repo>"'); process.exit(2); }
443
443
  const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
444
444
  const dirty = git(['status', '--porcelain'], repo).split('\n').filter((l) => l.trim() && !l.includes('.promptwheel'));
445
- if (dirty.length) { console.error('working tree not clean — commit or stash first (improve needs a clean base to revert to)'); process.exit(2); }
445
+ if (dirty.length) { console.error('working tree not clean, commit or stash first (improve needs a clean base to revert to)'); process.exit(2); }
446
446
 
447
447
  console.error(`▶ attempt: ${args.attempt}`);
448
448
  try { execSync(args.attempt, { cwd: repo, stdio: 'inherit' }); }
449
- catch (e) { console.error(` (attempt exited ${e.status ?? 1} — gating whatever it changed)`); }
449
+ catch (e) { console.error(` (attempt exited ${e.status ?? 1}, gating whatever it changed)`); }
450
450
 
451
451
  const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming, label: args.label ?? args.attempt });
452
452
  const noChange = report.metrics.every((m) => m.delta === 0 || m.delta == null);
@@ -455,17 +455,17 @@ function improve(argv) {
455
455
  // result + exit code express loop progress so `while improve; do :; done` converges:
456
456
  // 0 = kept a real win · 1 = guarded regression (reverted) · 3 = plateau/no-op OR inconclusive (reverted)
457
457
  let result, exit, note;
458
- if (report.verdict === 'fail') { result = 'regression'; exit = 1; revert(repo); note = '✗ guarded regression — reverted'; }
459
- else if (report.verdict === 'gamed') { result = 'gamed'; exit = 1; revert(repo); note = '🚩 gamed — a metric "improved" by editing tests/config, not source — reverted'; }
460
- else if (report.verdict === 'inconclusive') { result = 'inconclusive'; exit = 3; revert(repo); note = '≈ inconclusive — a guard measured nothing (inert); reverted'; }
458
+ if (report.verdict === 'fail') { result = 'regression'; exit = 1; revert(repo); note = '✗ guarded regression, reverted'; }
459
+ else if (report.verdict === 'gamed') { result = 'gamed'; exit = 1; revert(repo); note = '🚩 gamed, a metric "improved" by editing tests/config, not source, reverted'; }
460
+ else if (report.verdict === 'inconclusive') { result = 'inconclusive'; exit = 3; revert(repo); note = '≈ inconclusive, a guard measured nothing (inert); reverted'; }
461
461
  else if (noChange || improvedNames.length === 0) {
462
462
  result = 'plateau'; exit = 3; revert(repo);
463
- note = noChange ? '= no metric moved — reverted' : '= nothing improved beyond noise — reverted';
463
+ note = noChange ? '= no metric moved, reverted' : '= nothing improved beyond noise, reverted';
464
464
  } else {
465
465
  git(['add', '-A'], repo);
466
466
  execFileSync('git', ['commit', '-qm', `promptwheel: ${args.attempt} [improved ${improvedNames.join(', ')}]`], { cwd: repo });
467
467
  result = 'kept'; exit = 0;
468
- note = `✓ kept — committed ${git(['rev-parse', '--short', 'HEAD'], repo)} (improved ${improvedNames.join(', ')})`;
468
+ note = `✓ kept, committed ${git(['rev-parse', '--short', 'HEAD'], repo)} (improved ${improvedNames.join(', ')})`;
469
469
  }
470
470
 
471
471
  // stdout carries the value (JSON report or the human table); the decision line goes to
@@ -482,14 +482,14 @@ function revert(repo) {
482
482
  try { git(['clean', '-fd', '-e', '.promptwheel'], repo); } catch { /* best effort */ }
483
483
  }
484
484
 
485
- // Phase-5 seed: turn the accumulated reward stream into signal. Thin on purpose —
485
+ // Phase-5 seed: turn the accumulated reward stream into signal. Thin on purpose,
486
486
  // this is the substrate a future outcome-curated playbook / UCB work-discovery loop trains on,
487
487
  // NOT that loop itself. Just honest aggregation, no model.
488
488
  function insights(argv) {
489
489
  const args = parseArgs(argv);
490
490
  const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
491
491
  const f = join(repo, '.promptwheel', 'outcomes.jsonl');
492
- if (!existsSync(f)) { console.error('no outcome record yet — run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
492
+ if (!existsSync(f)) { console.error('no outcome record yet, run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
493
493
  const runs = readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
494
494
  const agg = {};
495
495
  for (const r of runs) for (const m of (r.metrics || [])) {
@@ -502,27 +502,27 @@ function insights(argv) {
502
502
  const rows = Object.entries(agg).map(([name, a]) => ({ name, ...a, lever: a.runs ? a.improved / a.runs : 0 }))
503
503
  .sort((x, y) => y.lever - x.lever);
504
504
  if (args.json) { console.log(JSON.stringify({ runs: runs.length, metrics: rows }, null, 2)); return; }
505
- console.log(`\nPromptWheel insights — ${runs.length} gated runs recorded\n`);
505
+ console.log(`\nPromptWheel insights, ${runs.length} gated runs recorded\n`);
506
506
  console.log(` ${'metric'.padEnd(18)} ${'runs'.padStart(5)} ${'impr'.padStart(5)} ${'regr'.padStart(5)} ${'inconc'.padStart(6)} ${'net Δ'.padStart(9)} lever`);
507
507
  for (const r of rows) {
508
508
  console.log(` ${r.name.padEnd(18)} ${String(r.runs).padStart(5)} ${String(r.improved).padStart(5)} ${String(r.regressed).padStart(5)} ${String(r.inconclusive).padStart(6)} ${String(r.net).padStart(9)} ${(r.lever * 100).toFixed(0)}%`);
509
509
  }
510
- console.log('\n lever = improved/runs — how reliably this metric actually responds. The');
510
+ console.log('\n lever = improved/runs, how reliably this metric actually responds. The');
511
511
  console.log(' highest-lever metrics are where an agent loop should spend its attempts.\n');
512
512
  }
513
513
 
514
514
  // ---------------------------------------------------------------------------
515
- // Phase 5 — outcome-curated playbook + UCB work-discovery.
515
+ // Phase 5, outcome-curated playbook + UCB work-discovery.
516
516
  // Unfrozen 2026-07-02 by explicit founder decision overriding D7 (see docs/LEARNING.md).
517
517
  // Design constraints that survive the unfreeze:
518
518
  // - PURE VIEW: the append-only ledger (.promptwheel/outcomes.jsonl) is the only store;
519
- // the playbook is re-derived on every read (decay at read time — no curator state to rot).
519
+ // the playbook is re-derived on every read (decay at read time, no curator state to rot).
520
520
  // - EVIDENCE-GATED: a key renders a claim only past MIN_MOVED weighted observations;
521
521
  // everything below that is counted, not asserted.
522
522
  // - CLAIM-GATED: no public compounding claim until bench/compounding-ab.mjs passes on
523
523
  // real usage data. The gate on the CLAIM outlived the gate on the CODE.
524
524
  // ---------------------------------------------------------------------------
525
- const HALF_LIFE = 20; // runs — an entry's weight halves every HALF_LIFE runs unless re-earned
525
+ const HALF_LIFE = 20; // runs, an entry's weight halves every HALF_LIFE runs unless re-earned
526
526
  const MIN_MOVED = 3; // weighted moved-observations before a key earns a rendered claim
527
527
  const EXPLORE_C = 1.0; // UCB exploration constant
528
528
 
@@ -580,7 +580,7 @@ function cohortNote(k) {
580
580
 
581
581
  function loadEntries(repo) {
582
582
  const runs = readLedger(repo);
583
- if (!runs.length) { console.error('no outcome record yet — run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
583
+ if (!runs.length) { console.error('no outcome record yet, run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
584
584
  const keys = foldOutcomes(runs);
585
585
  const totalW = keys.filter((k) => k.key === k.metric).reduce((s, k) => s + k.w, 0);
586
586
  const entries = keys.map((k) => ({ ...k, ...scoreKey(k, totalW), sufficient: k.moved >= MIN_MOVED }))
@@ -597,38 +597,38 @@ function playbook(argv) {
597
597
  const suff = entries.filter((e) => e.sufficient);
598
598
  const pct = (x) => `${Math.round(x * 100)}%`;
599
599
  const out = [];
600
- out.push(`## Earned playbook — ${runs.length} gated runs, decay half-life ${HALF_LIFE} runs`);
600
+ out.push(`## Earned playbook, ${runs.length} gated runs, decay half-life ${HALF_LIFE} runs`);
601
601
  out.push('*(every line below was measured by the gate, not asserted; entries decay unless re-earned)*', '');
602
602
  if (!suff.length) {
603
- out.push(`_Nothing has earned a claim yet — a key needs ≥${MIN_MOVED} weighted moved-observations. Keep gating; ${entries.length} keys are accumulating._`);
603
+ out.push(`_Nothing has earned a claim yet, a key needs ≥${MIN_MOVED} weighted moved-observations. Keep gating; ${entries.length} keys are accumulating._`);
604
604
  } else {
605
605
  for (const e of suff.slice(0, 20)) {
606
- out.push(`- **${e.key}** — helpful ${pct(e.p)} when it moves (${e.imp.toFixed(1)}✓/${e.reg.toFixed(1)}✗ weighted), responds in ${pct(e.move)} of runs, median effect ${e.effect}${cohortNote(e)}`);
606
+ out.push(`- **${e.key}**, helpful ${pct(e.p)} when it moves (${e.imp.toFixed(1)}✓/${e.reg.toFixed(1)}✗ weighted), responds in ${pct(e.move)} of runs, median effect ${e.effect}${cohortNote(e)}`);
607
607
  }
608
608
  const hidden = entries.length - suff.length;
609
- if (hidden > 0) out.push('', `_${hidden} more keys below the evidence threshold — counted, not asserted._`);
609
+ if (hidden > 0) out.push('', `_${hidden} more keys below the evidence threshold, counted, not asserted._`);
610
610
  }
611
611
  console.log(out.join('\n'));
612
612
  }
613
613
 
614
614
  // UCB work-discovery: where should the loop spend its NEXT attempt? Proposes measured
615
- // targets (metric/subsystem arms) — never code advice; the measurement stays the message.
615
+ // targets (metric/subsystem arms), never code advice; the measurement stays the message.
616
616
  function suggest(argv) {
617
617
  const args = parseArgs(argv);
618
618
  const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
619
619
  const { runs, entries } = loadEntries(repo);
620
620
  const arms = entries.filter((e) => e.key === e.metric || e.key.includes(' @ ')).sort((a, b) => b.ucb - a.ucb);
621
621
  if (args.json) { console.log(JSON.stringify({ runs: runs.length, thin: runs.length < 10, arms: arms.slice(0, 10) }, null, 2)); return; }
622
- console.log(`\nPromptWheel suggest — UCB over ${runs.length} gated runs${runs.length < 10 ? ' (thin record: exploration dominates — treat as a coin with opinions)' : ''}\n`);
622
+ console.log(`\nPromptWheel suggest, UCB over ${runs.length} gated runs${runs.length < 10 ? ' (thin record: exploration dominates, treat as a coin with opinions)' : ''}\n`);
623
623
  for (const a of arms.slice(0, 5)) {
624
624
  console.log(` ${a.ucb.toFixed(3)} ${a.key.padEnd(28)} lever ${a.lever.toFixed(3)} (helpful ${a.p}, responds ${a.move}) · evidence ${a.moved.toFixed(1)} moved / ${a.n} runs${a.sufficient ? '' : ' · below claim threshold'}`);
625
625
  }
626
- console.log('\n score = lever + exploration bonus — high scores are either proven levers or under-explored arms.\n');
626
+ console.log('\n score = lever + exploration bonus, high scores are either proven levers or under-explored arms.\n');
627
627
  }
628
628
 
629
- // backfill — seed the ledger from git history: replay past commits through the CURRENT
629
+ // backfill, seed the ledger from git history: replay past commits through the CURRENT
630
630
  // metrics (LEARNING.md harvest path 1). Deterministic, no LLM. Rows are cohort-tagged
631
- // 'backfill' — historical human commits are NOT live agent-loop evidence; the cohort
631
+ // 'backfill', historical human commits are NOT live agent-loop evidence; the cohort
632
632
  // machinery segments them and flags disagreement rather than averaging it away. The
633
633
  // conventional-commit type (fix/feat/refactor/…) becomes the change-type label for free.
634
634
  function backfill(argv) {
@@ -640,7 +640,7 @@ function backfill(argv) {
640
640
  if (!commits.length) { console.error('no commits to backfill'); process.exit(2); }
641
641
  const seen = new Set(readLedger(repo).filter((r) => r.cohort === 'backfill').map((r) => r.head));
642
642
  let done = 0, skipped = 0;
643
- console.log(`\nbackfilling ${commits.length} commits through the current metrics (gaming detection ${args.dgExplicit && args.detectGaming ? 'ON' : 'off — pass --detect-gaming to audit history too'})\n`);
643
+ console.log(`\nbackfilling ${commits.length} commits through the current metrics (gaming detection ${args.dgExplicit && args.detectGaming ? 'ON' : 'off, pass --detect-gaming to audit history too'})\n`);
644
644
  for (const c of commits) {
645
645
  try { git(['rev-parse', `${c}~1`], repo); } catch { skipped++; continue; } // root commit
646
646
  if (seen.has(short(repo, c))) { skipped++; continue; } // already recorded
@@ -650,12 +650,12 @@ function backfill(argv) {
650
650
  console.log(` ${report.head} ${report.verdict.toUpperCase().padEnd(6)} ${type ? `#${type}`.padEnd(10) : ''.padEnd(10)} ${subj.slice(0, 56)}`);
651
651
  done++;
652
652
  }
653
- console.log(`\n backfilled ${done}${skipped ? ` (skipped ${skipped}: root or already recorded)` : ''} — old commits that no longer build record as unmeasurable, never faked.`);
653
+ console.log(`\n backfilled ${done}${skipped ? ` (skipped ${skipped}: root or already recorded)` : ''}, old commits that no longer build record as unmeasurable, never faked.`);
654
654
  console.log(' next: promptwheel playbook · promptwheel suggest\n');
655
655
  }
656
656
 
657
657
  // ---------------------------------------------------------------------------
658
- // init — write a starter config so a newcomer isn't staring at a blank page
658
+ // init, write a starter config so a newcomer isn't staring at a blank page
659
659
  // ---------------------------------------------------------------------------
660
660
  const LINT_CMD = 'npx eslint . -f unix 2>/dev/null | grep -c " error " || true';
661
661
  const PRESETS = {
@@ -679,6 +679,10 @@ const PRESETS = {
679
679
  { name: 'suppressions', cmd: 'grep -rIoE "eslint-disable|@ts-(ignore|nocheck)|# ?type: ?ignore|# ?noqa|//nolint|#!?\\[allow\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
680
680
  { name: 'assertions', cmd: 'grep -rIoE "expect ?\\(|\\bassert|\\bt\\.(is|deepEqual|throws|truthy|falsy|not|ok) ?\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
681
681
  ] },
682
+ 'no-ai-tells': { desc: 'opt-in: fail the gate if the em-dash count rises (an AI writing tell); some projects use them on purpose, so it is not a default',
683
+ metrics: [
684
+ { name: 'em_dashes', cmd: 'grep -rIoe "—" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
685
+ ] },
682
686
  };
683
687
 
684
688
  function detectTestCmd(repo) {
@@ -686,26 +690,26 @@ function detectTestCmd(repo) {
686
690
  if (has('go.mod')) return 'go test ./...';
687
691
  if (has('Cargo.toml')) return 'cargo test';
688
692
  if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) return has('.venv/bin/pytest') ? '.venv/bin/pytest -q' : 'pytest -q';
689
- // only trust `npm test` when a test script actually exists — otherwise the metric can
693
+ // only trust `npm test` when a test script actually exists, otherwise the metric can
690
694
  // never pass and the gate would "protect" nothing (very common in Next.js app repos)
691
695
  try {
692
696
  if (JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).scripts?.test) return 'npm test --silent';
693
- } catch { /* no or unparsable package.json — fall through */ }
697
+ } catch { /* no or unparsable package.json, fall through */ }
694
698
  return 'echo "set your test command in promptwheel.config.json" && false';
695
699
  }
696
700
 
697
701
  // deps that live outside git + the env a test needs, defaulted per stack. init writes these into
698
- // config; the measurement bridge (bridgeEnv/runSetup) consumes them — keeps the engine agnostic.
702
+ // config; the measurement bridge (bridgeEnv/runSetup) consumes them, keeps the engine agnostic.
699
703
  function detectEnv(repo) {
700
704
  const has = (f) => existsSync(join(repo, f));
701
705
  // Python: link the venv (3rd-party deps) and put the worktree src FIRST on PYTHONPATH, so imports
702
706
  // resolve the MEASURED ref instead of an editable install pointing back at your original checkout.
703
707
  if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) return { linkDirs: ['.venv'], env: { PYTHONPATH: '{wt}/src:{wt}' } }; // src/ AND flat layouts; worktree src wins over an editable install
704
- if (has('Cargo.toml')) return { linkDirs: [] }; // do NOT link target/: `cargo test` WRITES there — linking would clobber the user's real dir and share a build cache across refs (measurement blindness). Build fresh in the worktree.
708
+ if (has('Cargo.toml')) return { linkDirs: [] }; // do NOT link target/: `cargo test` WRITES there, linking would clobber the user's real dir and share a build cache across refs (measurement blindness). Build fresh in the worktree.
705
709
  return {};
706
710
  }
707
711
 
708
- // only offer the lint metric where eslint actually exists — otherwise it reads 0 forever
712
+ // only offer the lint metric where eslint actually exists, otherwise it reads 0 forever
709
713
  // and a newcomer mistakes a metric that can't move for a clean repo.
710
714
  function hasEslint(repo) {
711
715
  const files = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
@@ -726,18 +730,18 @@ function init(argv) {
726
730
  const repo = (() => { try { return git(['rev-parse', '--show-toplevel'], process.cwd()); } catch { return process.cwd(); } })();
727
731
  const out = join(repo, 'promptwheel.config.json');
728
732
  if (existsSync(out) && !argv.includes('--force')) {
729
- console.error('promptwheel.config.json already exists — pass --force to overwrite'); process.exit(2);
733
+ console.error('promptwheel.config.json already exists, pass --force to overwrite'); process.exit(2);
730
734
  }
731
735
  const pi = argv.indexOf('--preset');
732
736
  const presetName = pi >= 0 ? argv[pi + 1] : null;
733
737
  let metrics, note;
734
738
  if (presetName) {
735
739
  const p = PRESETS[presetName];
736
- if (!p) { console.error(`unknown preset "${presetName}" — try: promptwheel init --list`); process.exit(2); }
740
+ if (!p) { console.error(`unknown preset "${presetName}", try: promptwheel init --list`); process.exit(2); }
737
741
  metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
738
742
  metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
739
743
  // pack-backed presets: the referenced scripts live in THIS package, not
740
- // the user's repo — copy them in so the generated config is
744
+ // the user's repo, copy them in so the generated config is
741
745
  // self-contained and committable (a bare reference silently 404'd)
742
746
  metrics = metrics.map((m) => {
743
747
  const pk = /(^|\s)(packs\/[\w-]+)\//.exec(m.cmd);
@@ -752,7 +756,7 @@ function init(argv) {
752
756
  note = presetName;
753
757
  } else {
754
758
  // default = the headline posture: guarded tests + the antihack tripwires, so the FIRST
755
- // cheat a newcomer tries (gut the tests while the metric stays flat) fails the gate —
759
+ // cheat a newcomer tries (gut the tests while the metric stays flat) fails the gate,
756
760
  // "catch your agent cheating" must be true out of the box, not only under a preset.
757
761
  const testCmd = detectTestCmd(repo);
758
762
  const lint = hasEslint(repo);
@@ -763,7 +767,7 @@ function init(argv) {
763
767
  const envCfg = presetName ? {} : detectEnv(repo);
764
768
  if (envCfg.linkDirs) note += ` · linking ${envCfg.linkDirs.join(', ')}`;
765
769
  writeFileSync(out, JSON.stringify({ repeat: 1, ...envCfg, metrics }, null, 2) + '\n');
766
- console.log(`✓ wrote promptwheel.config.json — ${note}`);
770
+ console.log(`✓ wrote promptwheel.config.json, ${note}`);
767
771
  console.log('\n next: promptwheel run --working # gate your uncommitted changes');
768
772
  console.log(' promptwheel init --list # other presets (llm-eval, bundle-size, …)');
769
773
  }
@@ -787,7 +791,7 @@ function guards(argv) {
787
791
  source: m.__src, override: !!m.__override, ...(hist[m.name] || { runs: 0, flagged: 0, last: null }),
788
792
  }));
789
793
  if (args.json) { console.log(JSON.stringify({ guards: rows }, null, 2)); return; }
790
- console.log('\nPromptWheel guardrails — effective set for this repo\n');
794
+ console.log('\nPromptWheel guardrails, effective set for this repo\n');
791
795
  for (const r of rows) {
792
796
  const prov = r.override ? 'local override' : (r.source === 'local' ? 'local' : `inherited ← ${r.source}`);
793
797
  const rec = r.guard ? (r.runs ? `· ${r.flagged} flagged / ${r.runs} runs` : '· no runs yet') : '';
@@ -803,13 +807,13 @@ function printHuman(r) {
803
807
  console.log(`\nPromptWheel ${r.base} → ${r.head}${r.repeat > 1 ? ` (×${r.repeat})` : ''}\n`);
804
808
  for (const m of r.metrics) {
805
809
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
806
- const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
810
+ const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
807
811
  console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
808
- if (m.inert) console.log(' ⚠ never passed at either ref — this guard is protecting nothing (check its command)');
809
- if (m.gamed === true) console.log(` 🚩 GAMED — ${m.gamingReason}`);
812
+ if (m.inert) console.log(' ⚠ never passed at either ref, this guard is protecting nothing (check its command)');
813
+ if (m.gamed === true) console.log(` 🚩 GAMED, ${m.gamingReason}`);
810
814
  }
811
- console.log(`\n VERDICT: ${r.verdict.toUpperCase()}${r.verdict === 'fail' ? ' — a guarded metric regressed (beyond noise)' : r.verdict === 'gamed' ? ' — a metric "improved" by editing the goalposts, not the source' : r.verdict === 'inconclusive' ? ' — a guard is inert (measured nothing); a pass cannot be certified' : ''}`);
812
- if (r.verdict === 'fail') console.log(' intentional? loosen that guard locally in promptwheel.config.json (guard:false, or override the inherited metric by name) — `promptwheel guards` shows the effective set');
815
+ console.log(`\n VERDICT: ${r.verdict.toUpperCase()}${r.verdict === 'fail' ? ', a guarded metric regressed (beyond noise)' : r.verdict === 'gamed' ? ', a metric "improved" by editing the goalposts, not the source' : r.verdict === 'inconclusive' ? ', a guard is inert (measured nothing); a pass cannot be certified' : ''}`);
816
+ if (r.verdict === 'fail') console.log(' intentional? loosen that guard locally in promptwheel.config.json (guard:false, or override the inherited metric by name), `promptwheel guards` shows the effective set');
813
817
  console.log('');
814
818
  }
815
819
 
@@ -818,12 +822,12 @@ function renderMarkdown(r) {
818
822
  const icon = r.verdict === 'pass' ? '✅' : r.verdict === 'gamed' ? '🚩' : r.verdict === 'inconclusive' ? '🟡' : '❌';
819
823
  const sIcon = { improved: '🟢', regressed: '🔴', unchanged: '⚪', inconclusive: '🟡', unmeasurable: '⚫' };
820
824
  const rows = r.metrics.map((m) => {
821
- const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
825
+ const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
822
826
  const status = m.gamed === true ? `🚩 gamed (${(m.retained * 100).toFixed(0)}% survives source-only)` : `${sIcon[m.status] || ''} ${m.status}${m.inert ? ' ⚠ never passes' : ''}`;
823
827
  return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${status} | ${m.confidence} |`;
824
828
  }).join('\n');
825
829
  return [
826
- `### ${icon} PromptWheel — outcome gate: **${r.verdict.toUpperCase()}**`,
830
+ `### ${icon} PromptWheel, outcome gate: **${r.verdict.toUpperCase()}**`,
827
831
  '',
828
832
  `\`${r.base} → ${r.head}\` · ${r.mode} mode${r.repeat > 1 ? ` · ×${r.repeat}` : ''}`,
829
833
  '',
@@ -831,7 +835,7 @@ function renderMarkdown(r) {
831
835
  '|---|--:|--:|--:|---|---|',
832
836
  rows,
833
837
  '',
834
- r.verdict === 'gamed' ? '> 🚩 A metric "improved" only because the agent edited tests/config/grader — not the source. The win does not survive a source-only re-run.' : r.verdict === 'fail' ? '> ❌ A 🛡️ guarded metric regressed beyond the noise band.' : r.verdict === 'inconclusive' ? '> 🟡 A 🛡️ guarded check is **inert** — it never runs, so nothing was actually verified. Fix its command before trusting a pass.' : '> ✅ No guarded metric regressed.',
838
+ r.verdict === 'gamed' ? '> 🚩 A metric "improved" only because the agent edited tests/config/grader, not the source. The win does not survive a source-only re-run.' : r.verdict === 'fail' ? '> ❌ A 🛡️ guarded metric regressed beyond the noise band.' : r.verdict === 'inconclusive' ? '> 🟡 A 🛡️ guarded check is **inert**, it never runs, so nothing was actually verified. Fix its command before trusting a pass.' : '> ✅ No guarded metric regressed.',
835
839
  '',
836
840
  '<sub>🛡️ = guard · _prove every change moved a metric_ · [PromptWheel](https://github.com/promptwheel-ai/promptwheel)</sub>',
837
841
  ].join('\n');
@@ -873,11 +877,11 @@ function main() {
873
877
  else if (cmd === 'guards') guards(rest);
874
878
  else {
875
879
  console.log([
876
- 'PromptWheel — catch your agent cheating. Prove a change moved a real metric (and that the agent earned it, not gamed it). The per-turn reward + source-only audit for AI coding loops.',
880
+ 'PromptWheel, catch your agent cheating. Prove a change moved a real metric (and that the agent earned it, not gamed it). The per-turn reward + source-only audit for AI coding loops.',
877
881
  '',
878
882
  ' promptwheel init [--preset <name> | --list] write a starter config for your stack',
879
883
  ' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
880
- ' catches reward-hacking BY DEFAULT — re-proves each win from SOURCE edits alone (verdict GAMED,',
884
+ ' catches reward-hacking BY DEFAULT, re-proves each win from SOURCE edits alone (verdict GAMED,',
881
885
  ' exit 2, when a metric only moved by editing tests/config/grader). Use --no-detect-gaming for the bare gate.',
882
886
  ' promptwheel run --working gate uncommitted changes (incl. newly added files)',
883
887
  ' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
@@ -890,7 +894,7 @@ function main() {
890
894
  '',
891
895
  'Loop it: while promptwheel improve --attempt "$AGENT"; do :; done # stops on plateau/regression',
892
896
  'Config: promptwheel.config.json → { extends?, metrics:[{ name, cmd, direction, extract?, guard? }] } (or: promptwheel init)',
893
- ' extends: a path (or array) to a shared base config — repos INHERIT its guardrails; local metrics override by name.',
897
+ ' extends: a path (or array) to a shared base config, repos INHERIT its guardrails; local metrics override by name.',
894
898
  ].join('\n'));
895
899
  process.exit(cmd ? 2 : 0);
896
900
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.4.5",
4
- "description": "A deterministic, zero-LLM check for your coding loop. It proves every change earned its win by re-running the result from the agent's source edits alone: it re-proves the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook.",
3
+ "version": "0.4.6",
4
+ "description": "A deterministic, zero-LLM check for your coding loop. It proves every change earned its win by re-running the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "promptwheel": "bin/promptwheel.mjs",