promptwheel 0.0.1 → 0.1.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/README.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  [![CI](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml/badge.svg)](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Node ≥18](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![deps: zero](https://img.shields.io/badge/deps-zero-blue)
4
4
 
5
- **The trustworthy per-turn reward for AI coding loops proves a turn moved a metric without regressing another.**
5
+ **Catch your agent cheating** the deterministic auditor that flags when your AI coding agent gamed its own success metric.
6
6
 
7
- 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 measures your real repo metrics in throwaway worktrees, refuses to trust a delta inside the noise, and answers one question *did this turn earn its keep?* — 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.)
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
- > 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") is a solved, commoditized problem; the trustworthy reward signal is the open one.
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
+
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
+
13
+ > 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.
10
14
 
11
15
  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.
12
16
 
@@ -25,12 +29,36 @@ PromptWheel a1b2c3d → e4f5g6h (×5)
25
29
 
26
30
  Exit `0` on pass, `1` on fail (CI-friendly). No build step, zero dependencies, Node 18+.
27
31
 
32
+ ## Catch your agent cheating — `--detect-gaming`
33
+
34
+ The headline feature. 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)**:
35
+
36
+ ```
37
+ $ promptwheel run --detect-gaming
38
+
39
+ PromptWheel base → head
40
+ ▲ tests_pass 0 → 1 (+1, improved) [guard✓, high]
41
+ 🚩 GAMED — the "win" changed zero production-source files — only test/config/grader/golden
42
+ VERDICT: GAMED — a metric "improved" by editing the goalposts, not the source
43
+ ```
44
+
45
+ Inline source-file suppressions (`@ts-nocheck`, `eslint-disable`, `# noqa`) are a different shape, caught by the **`antihack` preset** — a target metric plus tripwire guards (`test_count`, `skipped_tests`, `suppressions`, `assertions`) that fail when a "win" introduces them:
46
+
47
+ ```bash
48
+ npx promptwheel init --preset antihack # target + tripwire guards
49
+ npx promptwheel run --detect-gaming # exit 0 = real win · 1 = regression · 2 = GAMED
50
+ ```
51
+
52
+ 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.
53
+
54
+ 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).
55
+
28
56
  ## Use
29
57
 
30
58
  ```bash
31
59
  # 0. write a starter config for your stack (or hand-write promptwheel.config.json)
32
60
  npx promptwheel init # detects stack → guarded test metric + lint
33
- npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval
61
+ npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval · antihack
34
62
 
35
63
  # measure a change
36
64
  npx promptwheel run # base = merge-base with main, head = HEAD
@@ -45,7 +73,7 @@ npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
45
73
  npx promptwheel insights
46
74
  ```
47
75
 
48
- It never touches your working tree — every measurement runs in a throwaway worktree. Every gated run appends to `.promptwheel/outcomes.jsonl` (commit it to build the per-repo "what moves what" record; `--no-record` to skip).
76
+ **Footprint:** it never touches your working tree — every measurement runs in a throwaway git worktree **in your system temp dir** (one at a time; `node_modules` is symlinked, not copied), removed when the run finishes. The only thing PromptWheel writes to your repo is the optional `.promptwheel/outcomes.jsonl` record — commit it 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).
49
77
 
50
78
  ## Loop patterns
51
79
 
@@ -110,6 +138,25 @@ The Action runs straight from its own checkout — no npm install, no build. See
110
138
  - **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).
111
139
  - **direction** — `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
112
140
  - **guard** — `true` = a *trusted* regression **fails** the gate; `false` = informational.
141
+ - **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.
142
+
143
+ ## Guardrails & inheritance
144
+
145
+ To see what's actually enforced in a repo — including guards inherited from a shared config:
146
+
147
+ ```bash
148
+ promptwheel guards
149
+ ```
150
+
151
+ Teams keep one shared base config and have every repo inherit it via `extends`:
152
+
153
+ ```json
154
+ // promptwheel.config.json
155
+ { "extends": "./promptwheel.base.json",
156
+ "metrics": [ { "name": "cost_per_run_usd", "guard": false } ] } // loosen one inherited guard, locally
157
+ ```
158
+
159
+ `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.
113
160
 
114
161
  ## Trust model — the point of the whole thing
115
162
 
@@ -129,6 +176,8 @@ The accumulated record of **which change-types move which metrics** is the asset
129
176
 
130
177
  ## Docs
131
178
 
179
+ - [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.
180
+ - [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.
132
181
  - [docs/VISION.md](docs/VISION.md) — why we pivoted from orchestrator to outcome gate, the thesis, the moat, the open-core model.
133
182
  - [docs/ROADMAP.md](docs/ROADMAP.md) — the phased plan and the ship-now/stay-thin guardrails.
134
183
  - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the engine works: schemas, extract modes, the trust/noise model.
@@ -138,7 +187,7 @@ The accumulated record of **which change-types move which metrics** is the asset
138
187
  ## Develop
139
188
 
140
189
  ```bash
141
- npm test # 20 dep-free tests (node:test) — unit + integration, no dependencies
190
+ npm test # 37 dep-free tests (node:test) — unit + integration, no dependencies
142
191
  ```
143
192
 
144
193
  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.
@@ -154,6 +203,8 @@ The engine is one importable file; pure helpers are exported for unit tests, the
154
203
  - [x] loop-consumable `improve`: exit `0` kept / `1` regression / `3` plateau + `--json result`
155
204
  - [x] `promptwheel init` + presets — zero-config onboarding
156
205
  - [x] `insights` — reward-stream aggregation (Phase-5 seed)
157
- - [ ] npm publish (the lead magnet) · ACE-style learning + UCB work-discovery (**frozen** gated on data + ≥1 paid engagement; see [docs/LEARNING.md](docs/LEARNING.md))
206
+ - [x] `--detect-gaming` reward-hack detection: re-prove the win from source edits alone + `antihack` preset
207
+ - [x] npm publish — `promptwheel@0.1.0` (the lead magnet, shipped 2026-06)
208
+ - [ ] ACE-style learning + UCB work-discovery (**frozen** — gated on data + ≥1 paid engagement; see [docs/LEARNING.md](docs/LEARNING.md))
158
209
 
159
- > Status: v0, runnable, all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
210
+ > Status: **published v0.1.0** — the consulting lead magnet (npm `promptwheel@0.1.0`), all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
@@ -11,9 +11,9 @@
11
11
  // promptwheel run [--base R] [--head R] [--repeat N] [--json]
12
12
 
13
13
  import { execSync, execFileSync } from 'node:child_process';
14
- import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync, readdirSync, statSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
- import { join } from 'node:path';
16
+ import { join, dirname, isAbsolute, relative } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
 
19
19
  // ---------------------------------------------------------------------------
@@ -28,11 +28,46 @@ const median = (xs) => {
28
28
  };
29
29
  const spread = (xs) => { const a = xs.filter((x) => x != null); return a.length ? Math.max(...a) - Math.min(...a) : 0; };
30
30
 
31
+ function readConfigFile(p) {
32
+ try { return JSON.parse(readFileSync(p, 'utf8')); }
33
+ catch (e) { console.error(`invalid config ${p}: ${e.message}`); process.exit(2); }
34
+ }
35
+
36
+ const relOf = (repo, p) => { try { return relative(repo, p) || p; } catch { return p; } };
37
+
38
+ // resolve a config + its `extends` chain into a flat, provenance-tagged metric list.
39
+ // `extends` is a path (or array of paths) to base configs, relative to the config file:
40
+ // a repo INHERITS their guardrails, and a local metric of the same name overrides the inherited one.
41
+ function resolveConfig(p, repo, label, seen) {
42
+ const abs = realpathSync(p);
43
+ if (seen.has(abs)) { console.error(`config inheritance cycle at ${relOf(repo, p)}`); process.exit(2); }
44
+ seen.add(abs);
45
+ try {
46
+ const cfg = readConfigFile(p);
47
+ const byName = new Map();
48
+ const scalars = {};
49
+ for (const ref of [].concat(cfg.extends || [])) {
50
+ const bp = isAbsolute(ref) ? ref : join(dirname(p), ref);
51
+ if (!existsSync(bp)) { console.error(`extends target not found: ${ref} (from ${relOf(repo, p)})`); process.exit(2); }
52
+ const base = resolveConfig(bp, repo, relOf(repo, bp), seen);
53
+ for (const m of base.metrics) byName.set(m.name, m);
54
+ for (const k of ['repeat', 'linkNodeModules', 'record']) if (base[k] !== undefined) scalars[k] = base[k];
55
+ }
56
+ for (const m of (cfg.metrics || [])) {
57
+ byName.set(m.name, { ...m, __src: label, __override: byName.has(m.name) });
58
+ }
59
+ for (const k of ['repeat', 'linkNodeModules', 'record']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
60
+ return { ...scalars, metrics: [...byName.values()] };
61
+ } finally {
62
+ seen.delete(abs); // only ACTIVE ancestors are a cycle — a diamond (a base reached two ways) is fine
63
+ }
64
+ }
65
+
31
66
  function loadConfig(repo) {
32
67
  const candidates = ['promptwheel.config.json', 'outcome-gate.config.json']; // back-compat alias
33
68
  const p = candidates.map((c) => join(repo, c)).find(existsSync);
34
69
  if (!p) { console.error('no promptwheel.config.json — run: promptwheel init (writes one for your stack)'); process.exit(2); }
35
- const cfg = JSON.parse(readFileSync(p, 'utf8'));
70
+ const cfg = resolveConfig(p, repo, 'local', new Set());
36
71
  if (!Array.isArray(cfg.metrics) || cfg.metrics.length === 0) {
37
72
  console.error('config.metrics must be a non-empty array'); process.exit(2);
38
73
  }
@@ -120,6 +155,80 @@ function evaluate(m, beforeS, afterS, repeat) {
120
155
  return { before, after, delta, status, ok, confidence, noise };
121
156
  }
122
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // gaming detection (antihack): re-prove a "win" using ONLY the agent's source edits.
160
+ // If the improvement evaporates once test/config/grader/golden changes are reverted,
161
+ // the agent moved the goalposts (edited the test, mocked the grader, suppressed the
162
+ // lint rule, deleted the feature) instead of earning it. Pure arithmetic, fully
163
+ // explainable — the thing the loop-owner structurally won't ship about its own agent.
164
+ // ---------------------------------------------------------------------------
165
+ const NON_SOURCE = [
166
+ /(^|\/)(tests?|__tests?__|spec|e2e|fixtures|snapshots|__snapshots__|__mocks__)\//i,
167
+ /\.(test|spec)\.[cm]?[jt]sx?$/i,
168
+ /(^|\/)tests?\.[cm]?[jt]sx?$/i, // a file literally named test.js / tests.ts
169
+ /(^|\/)test_[^/]*\.py$/i, /_test\.py$/i, /(^|\/)conftest\.py$/i,
170
+ /\.snap$/i, /(^|\/)golden[^/]*$/i, /\.golden$/i,
171
+ /(^|\/)(eval|grader|score)[^/]*\.[cm]?[jt]s$/i,
172
+ /(^|\/)(jest|vitest|playwright|cypress|karma|babel|eslint|tsconfig|pytest)[^/]*\.(json|[cm]?[jt]s|ya?ml|ini|cfg|toml)$/i,
173
+ /(^|\/)\.(eslintrc|babelrc|prettierrc)[^/]*$/i,
174
+ /(^|\/)(pytest\.ini|setup\.cfg|\.flake8|tox\.ini|eslint\.config\.[cm]?js)$/i,
175
+ ];
176
+ const isNonSource = (p) => NON_SOURCE.some((re) => re.test(p));
177
+
178
+ // split the agent's changed files into production-source vs test/config/grader/golden
179
+ function changedSourcePaths(repo, base, head) {
180
+ const all = git(['diff', '--name-only', base, head], repo).split('\n').filter(Boolean);
181
+ return { source: all.filter((p) => !isNonSource(p)), nonSource: all.filter(isNonSource) };
182
+ }
183
+
184
+ // measure `metric` in a worktree at `base` with ONLY the source slice of base→head applied.
185
+ function measureSourceOnly(repo, base, head, metric, linkNM, repeat) {
186
+ const { source } = changedSourcePaths(repo, base, head);
187
+ if (!source.length) return { samples: [], hadSourceChange: false }; // a "win" with zero source edits = goalposts moved
188
+ const wt = mkdtempSync(join(tmpdir(), 'promptwheel-src-'));
189
+ git(['worktree', 'add', '--quiet', '--detach', wt, base], repo);
190
+ try {
191
+ if (linkNM && existsSync(join(repo, 'node_modules')) && !existsSync(join(wt, 'node_modules'))) {
192
+ try { symlinkSync(join(repo, 'node_modules'), join(wt, 'node_modules')); } catch { /* */ }
193
+ }
194
+ const patch = git(['diff', base, head, '--', ...source], repo);
195
+ if (patch.trim()) {
196
+ const tryApply = (extra) => { execFileSync('git', ['apply', '--whitespace=nowarn', ...extra], { cwd: wt, input: patch + '\n', encoding: 'utf8' }); };
197
+ try { tryApply(['--3way']); }
198
+ catch { try { tryApply([]); } catch { return { samples: [], hadSourceChange: true, applyFailed: true }; } }
199
+ }
200
+ const samples = [];
201
+ for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, metric));
202
+ return { samples, hadSourceChange: true };
203
+ } finally {
204
+ try { git(['worktree', 'remove', '--force', wt], repo); } catch { rmSync(wt, { recursive: true, force: true }); }
205
+ }
206
+ }
207
+
208
+ // directional improvement of `val` over `before` (positive = better)
209
+ function gain(m, before, val) {
210
+ if (before == null || val == null) return null;
211
+ const dir = m.direction || 'up';
212
+ if (dir === 'down') return before - val;
213
+ if (dir === 'pass') return val === 1 ? (before !== 1 ? 1 : 0) : -1;
214
+ return val - before;
215
+ }
216
+
217
+ // a metric that improved full-diff: does the win survive when only source edits are applied?
218
+ // gamed = source edits alone reproduce < half the gain (the rest came from editing the goalposts).
219
+ function judgeGaming(m, srcResult) {
220
+ if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files — only test/config/grader/golden' };
221
+ if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly — inconclusive' };
222
+ const sourceOnly = median(srcResult.samples);
223
+ const full = gain(m, m.before, m.after);
224
+ if (full == null || full <= 0) return { gamed: false, sourceOnly, retained: 1, reason: 'no real improvement to re-prove' };
225
+ const retained = +(gain(m, m.before, sourceOnly) / full).toFixed(3);
226
+ const gamed = retained < 0.5;
227
+ return { gamed, sourceOnly, retained, reason: gamed
228
+ ? `only ${(retained * 100).toFixed(0)}% of the gain survives when test/config/grader changes are reverted — most of the "win" came from editing the goalposts`
229
+ : `${(retained * 100).toFixed(0)}% of the gain survives source-only — the source earned it` };
230
+ }
231
+
123
232
  // ---------------------------------------------------------------------------
124
233
  // main
125
234
  // ---------------------------------------------------------------------------
@@ -149,8 +258,30 @@ function workingSnapshot(repo) {
149
258
  }
150
259
  }
151
260
 
261
+ // self-heal: a hard-killed run (SIGKILL / power loss) can leave an orphaned worktree —
262
+ // its `finally` never ran. On the next run, drop stale worktree registry entries and any
263
+ // abandoned /tmp checkout, so nothing accumulates in your repo or temp dir. Never touches a
264
+ // live worktree (a still-registered dir, or a checkout younger than 1h).
265
+ function selfHeal(repo) {
266
+ try {
267
+ git(['worktree', 'prune'], repo); // remove registry entries whose dir is already gone (always safe)
268
+ const tracked = new Set(
269
+ git(['worktree', 'list', '--porcelain'], repo)
270
+ .split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice(9).trim()),
271
+ );
272
+ const tmp = tmpdir(), cutoff = Date.now() - 3600_000;
273
+ for (const name of readdirSync(tmp)) {
274
+ if (!name.startsWith('promptwheel-') && !name.startsWith('pw-idx-')) continue; // worktrees + temp-index dirs
275
+ const p = join(tmp, name);
276
+ if (tracked.has(p)) continue; // a live/registered worktree — leave it
277
+ try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
278
+ }
279
+ } catch { /* cleanup must never block the gate */ }
280
+ }
281
+
152
282
  // the shared core: measure a change (base→head) and return the structured report
153
283
  function gate(repo, opts) {
284
+ selfHeal(repo);
154
285
  const cfg = loadConfig(repo);
155
286
  const repeat = Math.max(1, opts.repeat ?? cfg.repeat ?? 1);
156
287
  const linkNM = cfg.linkNodeModules !== false;
@@ -160,6 +291,8 @@ function gate(repo, opts) {
160
291
  // measure uncommitted changes — tracked AND untracked — via a temp-index snapshot;
161
292
  // never touches the real index or working tree. (A loop agent's most common action
162
293
  // is to ADD a file; `git stash create` omits untracked, which silently reverted them.)
294
+ try { git(['rev-parse', '--verify', 'HEAD'], repo); }
295
+ catch { console.error('no commits yet — make an initial commit before gating --working changes'); process.exit(2); }
163
296
  base = 'HEAD';
164
297
  head = workingSnapshot(repo);
165
298
  } else {
@@ -173,7 +306,19 @@ function gate(repo, opts) {
173
306
  const ev = evaluate(m, before[m.name], after[m.name], repeat);
174
307
  return { name: m.name, direction: m.direction || 'up', guard: !!m.guard, ...ev };
175
308
  });
176
- const verdict = metrics.some((m) => m.guard && !m.ok) ? 'fail' : 'pass';
309
+ // antihack: re-prove every actual win with the agent's source edits alone
310
+ if (opts.detectGaming) {
311
+ for (const m of metrics) {
312
+ if (m.status !== 'improved') continue;
313
+ const cm = cfg.metrics.find((c) => c.name === m.name);
314
+ if (cm.gamingCheck === false) continue; // tripwire / test-side guards aren't re-proven from source (their gain legitimately lives in test files)
315
+ const j = judgeGaming(m, measureSourceOnly(repo, base, head, cm, linkNM, repeat));
316
+ m.gamed = j.gamed; m.sourceOnly = j.sourceOnly; m.retained = j.retained; m.gamingReason = j.reason;
317
+ }
318
+ }
319
+ const failed = metrics.some((m) => m.guard && !m.ok);
320
+ const gamed = metrics.some((m) => m.gamed === true);
321
+ const verdict = failed ? 'fail' : gamed ? 'gamed' : 'pass';
177
322
  const report = { base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs', verdict, metrics };
178
323
  if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
179
324
  return report;
@@ -182,11 +327,11 @@ function gate(repo, opts) {
182
327
  function run(argv) {
183
328
  const args = parseArgs(argv);
184
329
  const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
185
- const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord });
330
+ const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
186
331
  if (args.json) console.log(JSON.stringify(report, null, 2));
187
332
  else if (args.markdown) console.log(renderMarkdown(report));
188
333
  else printHuman(report);
189
- process.exit(report.verdict === 'pass' ? 0 : 1);
334
+ process.exit(report.verdict === 'pass' ? 0 : report.verdict === 'gamed' ? 2 : 1);
190
335
  }
191
336
 
192
337
  // the moat: append every gated run to a per-repo outcome record (best-effort, never fails the gate)
@@ -210,7 +355,7 @@ function improve(argv) {
210
355
  try { execSync(args.attempt, { cwd: repo, stdio: 'inherit' }); }
211
356
  catch (e) { console.error(` (attempt exited ${e.status ?? 1} — gating whatever it changed)`); }
212
357
 
213
- const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord });
358
+ const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
214
359
  const noChange = report.metrics.every((m) => m.delta === 0 || m.delta == null);
215
360
  const improvedNames = report.metrics.filter((m) => m.status === 'improved').map((m) => m.name);
216
361
 
@@ -218,6 +363,7 @@ function improve(argv) {
218
363
  // 0 = kept a real win · 1 = guarded regression (reverted) · 3 = plateau/no-op (reverted)
219
364
  let result, exit, note;
220
365
  if (report.verdict === 'fail') { result = 'regression'; exit = 1; revert(repo); note = '✗ guarded regression — reverted'; }
366
+ else if (report.verdict === 'gamed') { result = 'gamed'; exit = 1; revert(repo); note = '🚩 gamed — a metric "improved" by editing tests/config, not source — reverted'; }
221
367
  else if (noChange || improvedNames.length === 0) {
222
368
  result = 'plateau'; exit = 3; revert(repo);
223
369
  note = noChange ? '= no metric moved — reverted' : '= nothing improved beyond noise — reverted';
@@ -286,6 +432,14 @@ const PRESETS = {
286
432
  { name: 'eval_pass_rate', cmd: 'node eval.mjs', extract: 'number', direction: 'up', guard: true },
287
433
  { name: 'cost_per_run_usd', cmd: 'node estimate-cost.mjs', extract: 'number', direction: 'down', guard: true },
288
434
  ] },
435
+ 'antihack': { desc: 'catch reward-hacking: a target + tripwires; pairs with `run --detect-gaming` (source-only re-run)',
436
+ metrics: [
437
+ { name: 'tests_pass', cmd: '__TESTCMD__', extract: 'exit', direction: 'pass', guard: true },
438
+ { name: 'test_count', cmd: 'grep -rIoE "\\b(it|test|describe) ?\\(|def test_" --exclude-dir=node_modules --exclude-dir=.git . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
439
+ { name: 'skipped_tests', cmd: 'grep -rIoE "\\.(skip|only) ?\\(|xit ?\\(|@pytest\\.mark\\.skip" --exclude-dir=node_modules --exclude-dir=.git . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
440
+ { name: 'suppressions', cmd: 'grep -rIoE "eslint-disable|@ts-(ignore|nocheck)|# ?type: ?ignore|# ?noqa" --exclude-dir=node_modules --exclude-dir=.git . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
441
+ { name: 'assertions', cmd: 'grep -rIoE "expect ?\\(|\\bassert" --exclude-dir=node_modules --exclude-dir=.git . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
442
+ ] },
289
443
  };
290
444
 
291
445
  function detectTestCmd(repo) {
@@ -315,6 +469,7 @@ function init(argv) {
315
469
  const p = PRESETS[presetName];
316
470
  if (!p) { console.error(`unknown preset "${presetName}" — try: promptwheel init --list`); process.exit(2); }
317
471
  metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
472
+ metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
318
473
  note = presetName;
319
474
  } else {
320
475
  // sensible default: tests-pass (guarded) + lint (info — can't fail a newcomer's first run)
@@ -331,6 +486,34 @@ function init(argv) {
331
486
  console.log(' promptwheel init --list # other presets (llm-eval, bundle-size, …)');
332
487
  }
333
488
 
489
+ // observability: list the EFFECTIVE guardrails (incl. inherited) + each one's flag record
490
+ function guards(argv) {
491
+ const args = parseArgs(argv);
492
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
493
+ const cfg = loadConfig(repo);
494
+ const hist = {};
495
+ const f = join(repo, '.promptwheel', 'outcomes.jsonl');
496
+ if (existsSync(f)) for (const line of readFileSync(f, 'utf8').split('\n').filter(Boolean)) {
497
+ let r; try { r = JSON.parse(line); } catch { continue; }
498
+ for (const m of (r.metrics || [])) {
499
+ const h = hist[m.name] ??= { runs: 0, flagged: 0, last: null };
500
+ h.runs++; if (m.guard && m.ok === false) h.flagged++; h.last = m.status;
501
+ }
502
+ }
503
+ const rows = cfg.metrics.map((m) => ({
504
+ name: m.name, direction: m.direction || 'up', guard: !!m.guard,
505
+ source: m.__src, override: !!m.__override, ...(hist[m.name] || { runs: 0, flagged: 0, last: null }),
506
+ }));
507
+ if (args.json) { console.log(JSON.stringify({ guards: rows }, null, 2)); return; }
508
+ console.log('\nPromptWheel guardrails — effective set for this repo\n');
509
+ for (const r of rows) {
510
+ const prov = r.override ? 'local override' : (r.source === 'local' ? 'local' : `inherited ← ${r.source}`);
511
+ const rec = r.guard ? (r.runs ? `· ${r.flagged} flagged / ${r.runs} runs` : '· no runs yet') : '';
512
+ console.log(` ${r.guard ? '🛡️ GUARD' : '· info '} ${r.name.padEnd(18)} better=${r.direction.padEnd(5)} [${prov}] ${rec}`);
513
+ }
514
+ console.log('\n 🛡️ = enforced (a trusted regression fails the gate) · info = tracked only\n');
515
+ }
516
+
334
517
  const short = (repo, ref) => { try { return git(['rev-parse', '--short', ref], repo); } catch { return ref; } };
335
518
 
336
519
  function printHuman(r) {
@@ -340,17 +523,19 @@ function printHuman(r) {
340
523
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
341
524
  const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
342
525
  console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
526
+ if (m.gamed === true) console.log(` 🚩 GAMED — ${m.gamingReason}`);
343
527
  }
344
- console.log(`\n VERDICT: ${r.verdict.toUpperCase()}${r.verdict === 'fail' ? ' — a guarded metric regressed (beyond noise)' : ''}\n`);
528
+ 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' : ''}\n`);
345
529
  }
346
530
 
347
531
  // PR-comment markdown (rendering lives in the tool so the GitHub Action stays thin)
348
532
  function renderMarkdown(r) {
349
- const icon = r.verdict === 'pass' ? '✅' : '❌';
533
+ const icon = r.verdict === 'pass' ? '✅' : r.verdict === 'gamed' ? '🚩' : '❌';
350
534
  const sIcon = { improved: '🟢', regressed: '🔴', unchanged: '⚪', inconclusive: '🟡', unmeasurable: '⚫' };
351
535
  const rows = r.metrics.map((m) => {
352
536
  const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
353
- return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${sIcon[m.status] || ''} ${m.status} | ${m.confidence} |`;
537
+ const status = m.gamed === true ? `🚩 gamed (${(m.retained * 100).toFixed(0)}% survives source-only)` : `${sIcon[m.status] || ''} ${m.status}`;
538
+ return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${status} | ${m.confidence} |`;
354
539
  }).join('\n');
355
540
  return [
356
541
  `### ${icon} PromptWheel — outcome gate: **${r.verdict.toUpperCase()}**`,
@@ -361,7 +546,7 @@ function renderMarkdown(r) {
361
546
  '|---|--:|--:|--:|---|---|',
362
547
  rows,
363
548
  '',
364
- r.verdict === 'fail' ? '> ❌ A 🛡️ guarded metric regressed beyond the noise band.' : '> ✅ No guarded metric regressed.',
549
+ 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.' : '> ✅ No guarded metric regressed.',
365
550
  '',
366
551
  '<sub>🛡️ = guard · _prove every change moved a metric_ · [PromptWheel](https://github.com/promptwheel-ai/promptwheel)</sub>',
367
552
  ].join('\n');
@@ -375,6 +560,7 @@ function parseArgs(argv) {
375
560
  else if (argv[i] === '--repeat') a.repeat = parseInt(argv[++i], 10);
376
561
  else if (argv[i] === '--working') a.working = true;
377
562
  else if (argv[i] === '--no-record') a.noRecord = true;
563
+ else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') a.detectGaming = true;
378
564
  else if (argv[i] === '--attempt') a.attempt = argv[++i];
379
565
  else if (argv[i] === '--json') a.json = true;
380
566
  else if (argv[i] === '--markdown') a.markdown = true;
@@ -384,7 +570,7 @@ function parseArgs(argv) {
384
570
 
385
571
  // pure, side-effect-free helpers are exported for unit testing; the CLI below only
386
572
  // runs when this file is executed directly (not when imported by the test suite).
387
- export { extract, evaluate, median, spread, renderMarkdown };
573
+ export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming };
388
574
 
389
575
  function main() {
390
576
  const [cmd, ...rest] = process.argv.slice(2);
@@ -392,6 +578,7 @@ function main() {
392
578
  else if (cmd === 'improve') improve(rest);
393
579
  else if (cmd === 'insights') insights(rest);
394
580
  else if (cmd === 'init') init(rest);
581
+ else if (cmd === 'guards') guards(rest);
395
582
  else {
396
583
  console.log([
397
584
  'PromptWheel — the per-turn reward for AI coding loops. Prove a change moved a metric.',
@@ -399,12 +586,16 @@ function main() {
399
586
  ' promptwheel init [--preset <name> | --list] write a starter config for your stack',
400
587
  ' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
401
588
  ' promptwheel run --working gate uncommitted changes (incl. newly added files)',
589
+ ' promptwheel run --detect-gaming re-prove each win with SOURCE edits alone — catch reward-hacking',
590
+ ' (verdict GAMED, exit 2, when a metric only moved by editing tests/config/grader)',
402
591
  ' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
403
592
  ' exit 0=kept · 1=regression · 3=plateau · add --json',
404
593
  ' promptwheel insights which metrics actually respond (loop memory)',
594
+ ' promptwheel guards show the effective guardrails (incl. inherited) + flag record',
405
595
  '',
406
596
  'Loop it: while promptwheel improve --attempt "$AGENT"; do :; done # stops on plateau/regression',
407
- 'Config: promptwheel.config.json → { metrics:[{ name, cmd, direction, extract?, guard? }] } (or: promptwheel init)',
597
+ 'Config: promptwheel.config.json → { extends?, metrics:[{ name, cmd, direction, extract?, guard? }] } (or: promptwheel init)',
598
+ ' extends: a path (or array) to a shared base config — repos INHERIT its guardrails; local metrics override by name.',
408
599
  ].join('\n'));
409
600
  process.exit(cmd ? 2 : 0);
410
601
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.0.1",
4
- "description": "The outcome gate for AI code prove every change moved a metric. Before/after in an isolated worktree, regression-guarded, noise-aware.",
3
+ "version": "0.1.1",
4
+ "description": "Catch your agent cheating — a deterministic, zero-LLM CLI that flags when an AI coding agent gamed its own metric (re-proves the win from the agent's source edits alone). Built on an outcome gate; runs in CI or a Claude Code hook.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "promptwheel": "bin/promptwheel.mjs",
@@ -13,6 +13,10 @@
13
13
  "gate": "node bin/promptwheel.mjs run",
14
14
  "test": "node --test"
15
15
  },
16
+ "repository": { "type": "git", "url": "git+https://github.com/promptwheel-ai/promptwheel.git" },
17
+ "homepage": "https://github.com/promptwheel-ai/promptwheel#readme",
18
+ "bugs": { "url": "https://github.com/promptwheel-ai/promptwheel/issues" },
19
+ "author": "Matthew Owens",
16
20
  "license": "MIT",
17
- "keywords": ["agent", "ci", "regression", "metrics", "verification", "ai-coding", "outcome", "promptwheel"]
21
+ "keywords": ["agent", "ci", "regression", "metrics", "verification", "ai-coding", "outcome", "evals", "loop", "promptwheel", "reward-hacking", "detect-gaming", "reward-hack-detection"]
18
22
  }