promptwheel 0.0.1 → 0.1.0

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,7 +2,11 @@
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
+
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
+
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?"*
6
10
 
7
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 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.)
8
12
 
@@ -25,12 +29,34 @@ 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
+
28
54
  ## Use
29
55
 
30
56
  ```bash
31
57
  # 0. write a starter config for your stack (or hand-write promptwheel.config.json)
32
58
  npx promptwheel init # detects stack → guarded test metric + lint
33
- npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval
59
+ npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval · antihack
34
60
 
35
61
  # measure a change
36
62
  npx promptwheel run # base = merge-base with main, head = HEAD
@@ -45,7 +71,7 @@ npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
45
71
  npx promptwheel insights
46
72
  ```
47
73
 
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).
74
+ **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
75
 
50
76
  ## Loop patterns
51
77
 
@@ -111,6 +137,24 @@ The Action runs straight from its own checkout — no npm install, no build. See
111
137
  - **direction** — `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
112
138
  - **guard** — `true` = a *trusted* regression **fails** the gate; `false` = informational.
113
139
 
140
+ ## Guardrails & inheritance
141
+
142
+ To see what's actually enforced in a repo — including guards inherited from a shared config:
143
+
144
+ ```bash
145
+ promptwheel guards
146
+ ```
147
+
148
+ Teams keep one shared base config and have every repo inherit it via `extends`:
149
+
150
+ ```json
151
+ // promptwheel.config.json
152
+ { "extends": "./promptwheel.base.json",
153
+ "metrics": [ { "name": "cost_per_run_usd", "guard": false } ] } // loosen one inherited guard, locally
154
+ ```
155
+
156
+ `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.
157
+
114
158
  ## Trust model — the point of the whole thing
115
159
 
116
160
  A number that jumps around between runs is worthless as a signal. PromptWheel won't pretend otherwise:
@@ -138,7 +182,7 @@ The accumulated record of **which change-types move which metrics** is the asset
138
182
  ## Develop
139
183
 
140
184
  ```bash
141
- npm test # 20 dep-free tests (node:test) — unit + integration, no dependencies
185
+ npm test # 36 dep-free tests (node:test) — unit + integration, no dependencies
142
186
  ```
143
187
 
144
188
  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 +198,7 @@ The engine is one importable file; pure helpers are exported for unit tests, the
154
198
  - [x] loop-consumable `improve`: exit `0` kept / `1` regression / `3` plateau + `--json result`
155
199
  - [x] `promptwheel init` + presets — zero-config onboarding
156
200
  - [x] `insights` — reward-stream aggregation (Phase-5 seed)
201
+ - [x] `--detect-gaming` — reward-hack detection: re-prove the win from source edits alone + `antihack` preset
157
202
  - [ ] 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))
158
203
 
159
204
  > Status: v0, runnable, 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,18 @@ 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
+ const j = judgeGaming(m, measureSourceOnly(repo, base, head, cm, linkNM, repeat));
315
+ m.gamed = j.gamed; m.sourceOnly = j.sourceOnly; m.retained = j.retained; m.gamingReason = j.reason;
316
+ }
317
+ }
318
+ const failed = metrics.some((m) => m.guard && !m.ok);
319
+ const gamed = metrics.some((m) => m.gamed === true);
320
+ const verdict = failed ? 'fail' : gamed ? 'gamed' : 'pass';
177
321
  const report = { base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs', verdict, metrics };
178
322
  if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
179
323
  return report;
@@ -182,11 +326,11 @@ function gate(repo, opts) {
182
326
  function run(argv) {
183
327
  const args = parseArgs(argv);
184
328
  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 });
329
+ const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
186
330
  if (args.json) console.log(JSON.stringify(report, null, 2));
187
331
  else if (args.markdown) console.log(renderMarkdown(report));
188
332
  else printHuman(report);
189
- process.exit(report.verdict === 'pass' ? 0 : 1);
333
+ process.exit(report.verdict === 'pass' ? 0 : report.verdict === 'gamed' ? 2 : 1);
190
334
  }
191
335
 
192
336
  // the moat: append every gated run to a per-repo outcome record (best-effort, never fails the gate)
@@ -210,7 +354,7 @@ function improve(argv) {
210
354
  try { execSync(args.attempt, { cwd: repo, stdio: 'inherit' }); }
211
355
  catch (e) { console.error(` (attempt exited ${e.status ?? 1} — gating whatever it changed)`); }
212
356
 
213
- const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord });
357
+ const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
214
358
  const noChange = report.metrics.every((m) => m.delta === 0 || m.delta == null);
215
359
  const improvedNames = report.metrics.filter((m) => m.status === 'improved').map((m) => m.name);
216
360
 
@@ -218,6 +362,7 @@ function improve(argv) {
218
362
  // 0 = kept a real win · 1 = guarded regression (reverted) · 3 = plateau/no-op (reverted)
219
363
  let result, exit, note;
220
364
  if (report.verdict === 'fail') { result = 'regression'; exit = 1; revert(repo); note = '✗ guarded regression — reverted'; }
365
+ else if (report.verdict === 'gamed') { result = 'gamed'; exit = 1; revert(repo); note = '🚩 gamed — a metric "improved" by editing tests/config, not source — reverted'; }
221
366
  else if (noChange || improvedNames.length === 0) {
222
367
  result = 'plateau'; exit = 3; revert(repo);
223
368
  note = noChange ? '= no metric moved — reverted' : '= nothing improved beyond noise — reverted';
@@ -286,6 +431,14 @@ const PRESETS = {
286
431
  { name: 'eval_pass_rate', cmd: 'node eval.mjs', extract: 'number', direction: 'up', guard: true },
287
432
  { name: 'cost_per_run_usd', cmd: 'node estimate-cost.mjs', extract: 'number', direction: 'down', guard: true },
288
433
  ] },
434
+ 'antihack': { desc: 'catch reward-hacking: a target + tripwires; pairs with `run --detect-gaming` (source-only re-run)',
435
+ metrics: [
436
+ { name: 'tests_pass', cmd: '__TESTCMD__', extract: 'exit', direction: 'pass', guard: true },
437
+ { 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 },
438
+ { 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 },
439
+ { 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 },
440
+ { 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 },
441
+ ] },
289
442
  };
290
443
 
291
444
  function detectTestCmd(repo) {
@@ -315,6 +468,7 @@ function init(argv) {
315
468
  const p = PRESETS[presetName];
316
469
  if (!p) { console.error(`unknown preset "${presetName}" — try: promptwheel init --list`); process.exit(2); }
317
470
  metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
471
+ metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
318
472
  note = presetName;
319
473
  } else {
320
474
  // sensible default: tests-pass (guarded) + lint (info — can't fail a newcomer's first run)
@@ -331,6 +485,34 @@ function init(argv) {
331
485
  console.log(' promptwheel init --list # other presets (llm-eval, bundle-size, …)');
332
486
  }
333
487
 
488
+ // observability: list the EFFECTIVE guardrails (incl. inherited) + each one's flag record
489
+ function guards(argv) {
490
+ const args = parseArgs(argv);
491
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
492
+ const cfg = loadConfig(repo);
493
+ const hist = {};
494
+ const f = join(repo, '.promptwheel', 'outcomes.jsonl');
495
+ if (existsSync(f)) for (const line of readFileSync(f, 'utf8').split('\n').filter(Boolean)) {
496
+ let r; try { r = JSON.parse(line); } catch { continue; }
497
+ for (const m of (r.metrics || [])) {
498
+ const h = hist[m.name] ??= { runs: 0, flagged: 0, last: null };
499
+ h.runs++; if (m.guard && m.ok === false) h.flagged++; h.last = m.status;
500
+ }
501
+ }
502
+ const rows = cfg.metrics.map((m) => ({
503
+ name: m.name, direction: m.direction || 'up', guard: !!m.guard,
504
+ source: m.__src, override: !!m.__override, ...(hist[m.name] || { runs: 0, flagged: 0, last: null }),
505
+ }));
506
+ if (args.json) { console.log(JSON.stringify({ guards: rows }, null, 2)); return; }
507
+ console.log('\nPromptWheel guardrails — effective set for this repo\n');
508
+ for (const r of rows) {
509
+ const prov = r.override ? 'local override' : (r.source === 'local' ? 'local' : `inherited ← ${r.source}`);
510
+ const rec = r.guard ? (r.runs ? `· ${r.flagged} flagged / ${r.runs} runs` : '· no runs yet') : '';
511
+ console.log(` ${r.guard ? '🛡️ GUARD' : '· info '} ${r.name.padEnd(18)} better=${r.direction.padEnd(5)} [${prov}] ${rec}`);
512
+ }
513
+ console.log('\n 🛡️ = enforced (a trusted regression fails the gate) · info = tracked only\n');
514
+ }
515
+
334
516
  const short = (repo, ref) => { try { return git(['rev-parse', '--short', ref], repo); } catch { return ref; } };
335
517
 
336
518
  function printHuman(r) {
@@ -340,17 +522,19 @@ function printHuman(r) {
340
522
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
341
523
  const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
342
524
  console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
525
+ if (m.gamed === true) console.log(` 🚩 GAMED — ${m.gamingReason}`);
343
526
  }
344
- console.log(`\n VERDICT: ${r.verdict.toUpperCase()}${r.verdict === 'fail' ? ' — a guarded metric regressed (beyond noise)' : ''}\n`);
527
+ 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
528
  }
346
529
 
347
530
  // PR-comment markdown (rendering lives in the tool so the GitHub Action stays thin)
348
531
  function renderMarkdown(r) {
349
- const icon = r.verdict === 'pass' ? '✅' : '❌';
532
+ const icon = r.verdict === 'pass' ? '✅' : r.verdict === 'gamed' ? '🚩' : '❌';
350
533
  const sIcon = { improved: '🟢', regressed: '🔴', unchanged: '⚪', inconclusive: '🟡', unmeasurable: '⚫' };
351
534
  const rows = r.metrics.map((m) => {
352
535
  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} |`;
536
+ const status = m.gamed === true ? `🚩 gamed (${(m.retained * 100).toFixed(0)}% survives source-only)` : `${sIcon[m.status] || ''} ${m.status}`;
537
+ return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${status} | ${m.confidence} |`;
354
538
  }).join('\n');
355
539
  return [
356
540
  `### ${icon} PromptWheel — outcome gate: **${r.verdict.toUpperCase()}**`,
@@ -361,7 +545,7 @@ function renderMarkdown(r) {
361
545
  '|---|--:|--:|--:|---|---|',
362
546
  rows,
363
547
  '',
364
- r.verdict === 'fail' ? '> ❌ A 🛡️ guarded metric regressed beyond the noise band.' : '> ✅ No guarded metric regressed.',
548
+ 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
549
  '',
366
550
  '<sub>🛡️ = guard · _prove every change moved a metric_ · [PromptWheel](https://github.com/promptwheel-ai/promptwheel)</sub>',
367
551
  ].join('\n');
@@ -375,6 +559,7 @@ function parseArgs(argv) {
375
559
  else if (argv[i] === '--repeat') a.repeat = parseInt(argv[++i], 10);
376
560
  else if (argv[i] === '--working') a.working = true;
377
561
  else if (argv[i] === '--no-record') a.noRecord = true;
562
+ else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') a.detectGaming = true;
378
563
  else if (argv[i] === '--attempt') a.attempt = argv[++i];
379
564
  else if (argv[i] === '--json') a.json = true;
380
565
  else if (argv[i] === '--markdown') a.markdown = true;
@@ -384,7 +569,7 @@ function parseArgs(argv) {
384
569
 
385
570
  // pure, side-effect-free helpers are exported for unit testing; the CLI below only
386
571
  // runs when this file is executed directly (not when imported by the test suite).
387
- export { extract, evaluate, median, spread, renderMarkdown };
572
+ export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming };
388
573
 
389
574
  function main() {
390
575
  const [cmd, ...rest] = process.argv.slice(2);
@@ -392,6 +577,7 @@ function main() {
392
577
  else if (cmd === 'improve') improve(rest);
393
578
  else if (cmd === 'insights') insights(rest);
394
579
  else if (cmd === 'init') init(rest);
580
+ else if (cmd === 'guards') guards(rest);
395
581
  else {
396
582
  console.log([
397
583
  'PromptWheel — the per-turn reward for AI coding loops. Prove a change moved a metric.',
@@ -399,12 +585,16 @@ function main() {
399
585
  ' promptwheel init [--preset <name> | --list] write a starter config for your stack',
400
586
  ' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
401
587
  ' promptwheel run --working gate uncommitted changes (incl. newly added files)',
588
+ ' promptwheel run --detect-gaming re-prove each win with SOURCE edits alone — catch reward-hacking',
589
+ ' (verdict GAMED, exit 2, when a metric only moved by editing tests/config/grader)',
402
590
  ' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
403
591
  ' exit 0=kept · 1=regression · 3=plateau · add --json',
404
592
  ' promptwheel insights which metrics actually respond (loop memory)',
593
+ ' promptwheel guards show the effective guardrails (incl. inherited) + flag record',
405
594
  '',
406
595
  '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)',
596
+ 'Config: promptwheel.config.json → { extends?, metrics:[{ name, cmd, direction, extract?, guard? }] } (or: promptwheel init)',
597
+ ' extends: a path (or array) to a shared base config — repos INHERIT its guardrails; local metrics override by name.',
408
598
  ].join('\n'));
409
599
  process.exit(cmd ? 2 : 0);
410
600
  }
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.0",
4
+ "description": "The trustworthy per-turn reward for AI coding loops — prove a change moved a metric without regressing another. Before/after in an isolated worktree, regression-guarded, noise-aware.",
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"]
18
22
  }