promptwheel 0.1.1 → 0.2.3
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 +18 -12
- package/bin/promptwheel.mjs +79 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,12 +29,12 @@ PromptWheel a1b2c3d → e4f5g6h (×5)
|
|
|
29
29
|
|
|
30
30
|
Exit `0` on pass, `1` on fail (CI-friendly). No build step, zero dependencies, Node 18+.
|
|
31
31
|
|
|
32
|
-
## Catch your agent cheating —
|
|
32
|
+
## Catch your agent cheating — on by default
|
|
33
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)**:
|
|
34
|
+
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)**:
|
|
35
35
|
|
|
36
36
|
```
|
|
37
|
-
$ promptwheel run
|
|
37
|
+
$ promptwheel run
|
|
38
38
|
|
|
39
39
|
PromptWheel base → head
|
|
40
40
|
▲ tests_pass 0 → 1 (+1, improved) [guard✓, high]
|
|
@@ -42,26 +42,27 @@ PromptWheel base → head
|
|
|
42
42
|
VERDICT: GAMED — a metric "improved" by editing the goalposts, not the source
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Inline source-file suppressions (`@ts-nocheck`, `eslint-disable`, `# noqa`)
|
|
45
|
+
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:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
npx promptwheel init
|
|
49
|
-
npx promptwheel run
|
|
48
|
+
npx promptwheel init # guarded tests + antihack tripwires by default
|
|
49
|
+
npx promptwheel run # detection ON by default · exit 0 win · 1 regression · 2 GAMED
|
|
50
|
+
# (add --no-detect-gaming for just the outcome gate)
|
|
50
51
|
```
|
|
51
52
|
|
|
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
|
+
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).
|
|
53
54
|
|
|
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
|
+
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/)**.
|
|
55
56
|
|
|
56
57
|
## Use
|
|
57
58
|
|
|
58
59
|
```bash
|
|
59
60
|
# 0. write a starter config for your stack (or hand-write promptwheel.config.json)
|
|
60
|
-
npx promptwheel init # detects stack → guarded
|
|
61
|
+
npx promptwheel init # detects stack → guarded tests + antihack tripwires (+ lint if eslint is set up)
|
|
61
62
|
npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval · antihack
|
|
62
63
|
|
|
63
64
|
# measure a change
|
|
64
|
-
npx promptwheel run # base = merge-base with main, head = HEAD
|
|
65
|
+
npx promptwheel run # base = merge-base with main, head = HEAD · reward-hack detection ON
|
|
65
66
|
npx promptwheel run --working # measure UNCOMMITTED changes (incl. newly added files)
|
|
66
67
|
npx promptwheel run --repeat 5 --json # measure 5× to establish a noise band, emit JSON
|
|
67
68
|
|
|
@@ -89,6 +90,8 @@ npx promptwheel run --working --json # branch on .verdict / per-metric .statu
|
|
|
89
90
|
|
|
90
91
|
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.
|
|
91
92
|
|
|
93
|
+
**Two callers, by design.** Your *agent* consumes the verdict — it's 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 a contestant can't referee itself (see [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md)). Same tool; *who calls it* is the difference between a reward and an audit.
|
|
94
|
+
|
|
92
95
|
## In Claude Code — plugin
|
|
93
96
|
|
|
94
97
|
Bring the gate into Claude Code as slash commands:
|
|
@@ -139,6 +142,7 @@ The Action runs straight from its own checkout — no npm install, no build. See
|
|
|
139
142
|
- **direction** — `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
|
|
140
143
|
- **guard** — `true` = a *trusted* regression **fails** the gate; `false` = informational.
|
|
141
144
|
- **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.
|
|
145
|
+
- **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`.
|
|
142
146
|
|
|
143
147
|
## Guardrails & inheritance
|
|
144
148
|
|
|
@@ -158,6 +162,8 @@ Teams keep one shared base config and have every repo inherit it via `extends`:
|
|
|
158
162
|
|
|
159
163
|
`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.
|
|
160
164
|
|
|
165
|
+
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.)
|
|
166
|
+
|
|
161
167
|
## Trust model — the point of the whole thing
|
|
162
168
|
|
|
163
169
|
A number that jumps around between runs is worthless as a signal. PromptWheel won't pretend otherwise:
|
|
@@ -187,7 +193,7 @@ The accumulated record of **which change-types move which metrics** is the asset
|
|
|
187
193
|
## Develop
|
|
188
194
|
|
|
189
195
|
```bash
|
|
190
|
-
npm test #
|
|
196
|
+
npm test # the dep-free suite (node:test) — unit + integration, no dependencies
|
|
191
197
|
```
|
|
192
198
|
|
|
193
199
|
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.
|
|
@@ -207,4 +213,4 @@ The engine is one importable file; pure helpers are exported for unit tests, the
|
|
|
207
213
|
- [x] npm publish — `promptwheel@0.1.0` (the lead magnet, shipped 2026-06)
|
|
208
214
|
- [ ] ACE-style learning + UCB work-discovery (**frozen** — gated on data + ≥1 paid engagement; see [docs/LEARNING.md](docs/LEARNING.md))
|
|
209
215
|
|
|
210
|
-
> Status: **published
|
|
216
|
+
> Status: **published** (npm `promptwheel`, v0.2.0) — all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -51,12 +51,12 @@ function resolveConfig(p, repo, label, seen) {
|
|
|
51
51
|
if (!existsSync(bp)) { console.error(`extends target not found: ${ref} (from ${relOf(repo, p)})`); process.exit(2); }
|
|
52
52
|
const base = resolveConfig(bp, repo, relOf(repo, bp), seen);
|
|
53
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];
|
|
54
|
+
for (const k of ['repeat', 'linkNodeModules', 'record', 'gamingThreshold']) if (base[k] !== undefined) scalars[k] = base[k];
|
|
55
55
|
}
|
|
56
56
|
for (const m of (cfg.metrics || [])) {
|
|
57
57
|
byName.set(m.name, { ...m, __src: label, __override: byName.has(m.name) });
|
|
58
58
|
}
|
|
59
|
-
for (const k of ['repeat', 'linkNodeModules', 'record']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
|
|
59
|
+
for (const k of ['repeat', 'linkNodeModules', 'record', 'gamingThreshold']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
|
|
60
60
|
return { ...scalars, metrics: [...byName.values()] };
|
|
61
61
|
} finally {
|
|
62
62
|
seen.delete(abs); // only ACTIVE ancestors are a cycle — a diamond (a base reached two ways) is fine
|
|
@@ -152,7 +152,10 @@ function evaluate(m, beforeS, afterS, repeat) {
|
|
|
152
152
|
|
|
153
153
|
// guards fail only on a TRUSTED regression (within-noise regressions are not failures)
|
|
154
154
|
const ok = m.guard ? !regressed : true;
|
|
155
|
-
|
|
155
|
+
// a guarded pass-metric that never passes is protecting NOTHING (broken test cmd,
|
|
156
|
+
// missing script, failed install) — surface it instead of folding into a green verdict.
|
|
157
|
+
const inert = !!m.guard && dir === 'pass' && before === 0 && after === 0;
|
|
158
|
+
return { before, after, delta, status, ok, confidence, noise, ...(inert ? { inert: true } : {}) };
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
// ---------------------------------------------------------------------------
|
|
@@ -169,7 +172,11 @@ const NON_SOURCE = [
|
|
|
169
172
|
/(^|\/)test_[^/]*\.py$/i, /_test\.py$/i, /(^|\/)conftest\.py$/i,
|
|
170
173
|
/\.snap$/i, /(^|\/)golden[^/]*$/i, /\.golden$/i,
|
|
171
174
|
/(^|\/)(eval|grader|score)[^/]*\.[cm]?[jt]s$/i,
|
|
172
|
-
|
|
175
|
+
// config files only — require a .config/.conf/.setup segment so PRODUCTION source that
|
|
176
|
+
// happens to be named after a tool (e.g. src/installers/eslint.ts) is NOT swept out of
|
|
177
|
+
// the source slice (a false sweep here can flag an honest win as GAMED).
|
|
178
|
+
/(^|\/)(jest|vitest|playwright|cypress|karma|babel|eslint|pytest)[^/]*\.(config|conf|setup)\.[^/]+$/i,
|
|
179
|
+
/(^|\/)tsconfig[^/]*\.json$/i,
|
|
173
180
|
/(^|\/)\.(eslintrc|babelrc|prettierrc)[^/]*$/i,
|
|
174
181
|
/(^|\/)(pytest\.ini|setup\.cfg|\.flake8|tox\.ini|eslint\.config\.[cm]?js)$/i,
|
|
175
182
|
];
|
|
@@ -215,17 +222,18 @@ function gain(m, before, val) {
|
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
// a metric that improved full-diff: does the win survive when only source edits are applied?
|
|
218
|
-
// gamed = source edits alone reproduce
|
|
219
|
-
|
|
225
|
+
// gamed = source edits alone reproduce less than `threshold` of the gain (default 0.5 — the
|
|
226
|
+
// rest came from editing the goalposts). Tune via `gamingThreshold` (config-level or per-metric).
|
|
227
|
+
function judgeGaming(m, srcResult, threshold = 0.5) {
|
|
220
228
|
if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files — only test/config/grader/golden' };
|
|
221
229
|
if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly — inconclusive' };
|
|
222
230
|
const sourceOnly = median(srcResult.samples);
|
|
223
231
|
const full = gain(m, m.before, m.after);
|
|
224
232
|
if (full == null || full <= 0) return { gamed: false, sourceOnly, retained: 1, reason: 'no real improvement to re-prove' };
|
|
225
233
|
const retained = +(gain(m, m.before, sourceOnly) / full).toFixed(3);
|
|
226
|
-
const gamed = retained <
|
|
234
|
+
const gamed = retained < threshold;
|
|
227
235
|
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`
|
|
236
|
+
? `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`
|
|
229
237
|
: `${(retained * 100).toFixed(0)}% of the gain survives source-only — the source earned it` };
|
|
230
238
|
}
|
|
231
239
|
|
|
@@ -279,9 +287,31 @@ function selfHeal(repo) {
|
|
|
279
287
|
} catch { /* cleanup must never block the gate */ }
|
|
280
288
|
}
|
|
281
289
|
|
|
290
|
+
// Python editable installs (src-layout `pip install -e .`) can pin imports to the ORIGINAL
|
|
291
|
+
// checkout: the worktree's tests then import your current tree at BOTH refs and every delta
|
|
292
|
+
// reads 0 — the gate is structurally blind on that repo shape. Undetectable from the verdict
|
|
293
|
+
// (it looks like "unchanged"), so detect the editable install itself and warn. Verified by a
|
|
294
|
+
// controlled repro (CHANGELOG 0.2.2).
|
|
295
|
+
function warnEditableInstall(repo) {
|
|
296
|
+
if (!existsSync(join(repo, 'pyproject.toml')) && !existsSync(join(repo, 'setup.py'))) return;
|
|
297
|
+
try {
|
|
298
|
+
const probe = [
|
|
299
|
+
'import site,glob,os,sys',
|
|
300
|
+
'repo=os.path.realpath(sys.argv[1])',
|
|
301
|
+
'sps=set(site.getsitepackages()+[site.getusersitepackages()])',
|
|
302
|
+
'files=[f for sp in sps for pat in ("__editable__*","*.egg-link","*.pth") for f in glob.glob(os.path.join(sp,pat)) if os.path.isfile(f)]',
|
|
303
|
+
'hit=any(repo in open(f,errors="ignore").read() for f in files)',
|
|
304
|
+
'print("HIT" if hit else "")',
|
|
305
|
+
].join('\n');
|
|
306
|
+
const out = execFileSync('python3', ['-c', probe, repo], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
307
|
+
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.');
|
|
308
|
+
} catch { /* no python / cannot tell — stay quiet */ }
|
|
309
|
+
}
|
|
310
|
+
|
|
282
311
|
// the shared core: measure a change (base→head) and return the structured report
|
|
283
312
|
function gate(repo, opts) {
|
|
284
313
|
selfHeal(repo);
|
|
314
|
+
warnEditableInstall(repo);
|
|
285
315
|
const cfg = loadConfig(repo);
|
|
286
316
|
const repeat = Math.max(1, opts.repeat ?? cfg.repeat ?? 1);
|
|
287
317
|
const linkNM = cfg.linkNodeModules !== false;
|
|
@@ -312,7 +342,7 @@ function gate(repo, opts) {
|
|
|
312
342
|
if (m.status !== 'improved') continue;
|
|
313
343
|
const cm = cfg.metrics.find((c) => c.name === m.name);
|
|
314
344
|
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));
|
|
345
|
+
const j = judgeGaming(m, measureSourceOnly(repo, base, head, cm, linkNM, repeat), cm.gamingThreshold ?? cfg.gamingThreshold ?? 0.5);
|
|
316
346
|
m.gamed = j.gamed; m.sourceOnly = j.sourceOnly; m.retained = j.retained; m.gamingReason = j.reason;
|
|
317
347
|
}
|
|
318
348
|
}
|
|
@@ -334,7 +364,7 @@ function run(argv) {
|
|
|
334
364
|
process.exit(report.verdict === 'pass' ? 0 : report.verdict === 'gamed' ? 2 : 1);
|
|
335
365
|
}
|
|
336
366
|
|
|
337
|
-
//
|
|
367
|
+
// persisted record: append every gated run to a per-repo outcome record (best-effort, never fails the gate)
|
|
338
368
|
function recordOutcome(repo, report) {
|
|
339
369
|
try {
|
|
340
370
|
const dir = join(repo, '.promptwheel');
|
|
@@ -343,7 +373,7 @@ function recordOutcome(repo, report) {
|
|
|
343
373
|
} catch { /* recording must never break the gate */ }
|
|
344
374
|
}
|
|
345
375
|
|
|
346
|
-
//
|
|
376
|
+
// keep-if-improved: run any agent/script, gate the result, keep the change ONLY if a metric improved
|
|
347
377
|
function improve(argv) {
|
|
348
378
|
const args = parseArgs(argv);
|
|
349
379
|
if (!args.attempt) { console.error('improve requires --attempt "<command that changes the repo>"'); process.exit(2); }
|
|
@@ -435,10 +465,10 @@ const PRESETS = {
|
|
|
435
465
|
'antihack': { desc: 'catch reward-hacking: a target + tripwires; pairs with `run --detect-gaming` (source-only re-run)',
|
|
436
466
|
metrics: [
|
|
437
467
|
{ 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 },
|
|
468
|
+
{ name: 'test_count', cmd: 'grep -rIoE "\\b(it|test|describe) ?\\(|def test_" --exclude-dir=node_modules --exclude-dir=.git --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
|
|
469
|
+
{ name: 'skipped_tests', cmd: 'grep -rIoE "\\.(skip|only) ?\\(|xit ?\\(|@pytest\\.mark\\.skip" --exclude-dir=node_modules --exclude-dir=.git --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
|
|
470
|
+
{ name: 'suppressions', cmd: 'grep -rIoE "eslint-disable|@ts-(ignore|nocheck)|# ?type: ?ignore|# ?noqa|//nolint|#!?\\[allow\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
|
|
471
|
+
{ name: 'assertions', cmd: 'grep -rIoE "expect ?\\(|\\bassert|\\bt\\.(is|deepEqual|throws|truthy|falsy|not|ok) ?\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
|
|
442
472
|
] },
|
|
443
473
|
};
|
|
444
474
|
|
|
@@ -447,10 +477,26 @@ function detectTestCmd(repo) {
|
|
|
447
477
|
if (has('go.mod')) return 'go test ./...';
|
|
448
478
|
if (has('Cargo.toml')) return 'cargo test';
|
|
449
479
|
if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) return 'pytest -q';
|
|
450
|
-
|
|
480
|
+
// only trust `npm test` when a test script actually exists — otherwise the metric can
|
|
481
|
+
// never pass and the gate would "protect" nothing (very common in Next.js app repos)
|
|
482
|
+
try {
|
|
483
|
+
if (JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).scripts?.test) return 'npm test --silent';
|
|
484
|
+
} catch { /* no or unparsable package.json — fall through */ }
|
|
451
485
|
return 'echo "set your test command in promptwheel.config.json" && false';
|
|
452
486
|
}
|
|
453
487
|
|
|
488
|
+
// only offer the lint metric where eslint actually exists — otherwise it reads 0 forever
|
|
489
|
+
// and a newcomer mistakes a metric that can't move for a clean repo.
|
|
490
|
+
function hasEslint(repo) {
|
|
491
|
+
const files = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
|
|
492
|
+
'.eslintrc', '.eslintrc.json', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.yml', '.eslintrc.yaml'];
|
|
493
|
+
if (files.some((f) => existsSync(join(repo, f)))) return true;
|
|
494
|
+
try {
|
|
495
|
+
const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
|
|
496
|
+
return !!(pkg.devDependencies?.eslint || pkg.dependencies?.eslint);
|
|
497
|
+
} catch { return false; }
|
|
498
|
+
}
|
|
499
|
+
|
|
454
500
|
function init(argv) {
|
|
455
501
|
if (argv.includes('--list')) {
|
|
456
502
|
console.log('PromptWheel presets (promptwheel init --preset <name>):\n');
|
|
@@ -472,13 +518,14 @@ function init(argv) {
|
|
|
472
518
|
metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
|
|
473
519
|
note = presetName;
|
|
474
520
|
} else {
|
|
475
|
-
//
|
|
521
|
+
// default = the headline posture: guarded tests + the antihack tripwires, so the FIRST
|
|
522
|
+
// cheat a newcomer tries (gut the tests while the metric stays flat) fails the gate —
|
|
523
|
+
// "catch your agent cheating" must be true out of the box, not only under a preset.
|
|
476
524
|
const testCmd = detectTestCmd(repo);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
note = `tests-pass + lint (detected: ${testCmd})`;
|
|
525
|
+
const lint = hasEslint(repo);
|
|
526
|
+
metrics = PRESETS['antihack'].metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: testCmd } : m);
|
|
527
|
+
if (lint) metrics = [...metrics, { name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }];
|
|
528
|
+
note = `tests + antihack tripwires${lint ? ' + lint' : ''} (detected: ${testCmd})`;
|
|
482
529
|
}
|
|
483
530
|
writeFileSync(out, JSON.stringify({ repeat: 1, metrics }, null, 2) + '\n');
|
|
484
531
|
console.log(`✓ wrote promptwheel.config.json — ${note}`);
|
|
@@ -523,9 +570,12 @@ function printHuman(r) {
|
|
|
523
570
|
const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
|
|
524
571
|
const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
|
|
525
572
|
console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
|
|
573
|
+
if (m.inert) console.log(' ⚠ never passed at either ref — this guard is protecting nothing (check its command)');
|
|
526
574
|
if (m.gamed === true) console.log(` 🚩 GAMED — ${m.gamingReason}`);
|
|
527
575
|
}
|
|
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' : ''}
|
|
576
|
+
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' : ''}`);
|
|
577
|
+
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');
|
|
578
|
+
console.log('');
|
|
529
579
|
}
|
|
530
580
|
|
|
531
581
|
// PR-comment markdown (rendering lives in the tool so the GitHub Action stays thin)
|
|
@@ -534,7 +584,7 @@ function renderMarkdown(r) {
|
|
|
534
584
|
const sIcon = { improved: '🟢', regressed: '🔴', unchanged: '⚪', inconclusive: '🟡', unmeasurable: '⚫' };
|
|
535
585
|
const rows = r.metrics.map((m) => {
|
|
536
586
|
const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
|
|
537
|
-
const status = m.gamed === true ? `🚩 gamed (${(m.retained * 100).toFixed(0)}% survives source-only)` : `${sIcon[m.status] || ''} ${m.status}`;
|
|
587
|
+
const status = m.gamed === true ? `🚩 gamed (${(m.retained * 100).toFixed(0)}% survives source-only)` : `${sIcon[m.status] || ''} ${m.status}${m.inert ? ' ⚠ never passes' : ''}`;
|
|
538
588
|
return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${status} | ${m.confidence} |`;
|
|
539
589
|
}).join('\n');
|
|
540
590
|
return [
|
|
@@ -553,7 +603,7 @@ function renderMarkdown(r) {
|
|
|
553
603
|
}
|
|
554
604
|
|
|
555
605
|
function parseArgs(argv) {
|
|
556
|
-
const a = { json: false };
|
|
606
|
+
const a = { json: false, detectGaming: true }; // reward-hack detection is ON by default; --no-detect-gaming opts out
|
|
557
607
|
for (let i = 0; i < argv.length; i++) {
|
|
558
608
|
if (argv[i] === '--base') a.base = argv[++i];
|
|
559
609
|
else if (argv[i] === '--head') a.head = argv[++i];
|
|
@@ -561,6 +611,7 @@ function parseArgs(argv) {
|
|
|
561
611
|
else if (argv[i] === '--working') a.working = true;
|
|
562
612
|
else if (argv[i] === '--no-record') a.noRecord = true;
|
|
563
613
|
else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') a.detectGaming = true;
|
|
614
|
+
else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') a.detectGaming = false;
|
|
564
615
|
else if (argv[i] === '--attempt') a.attempt = argv[++i];
|
|
565
616
|
else if (argv[i] === '--json') a.json = true;
|
|
566
617
|
else if (argv[i] === '--markdown') a.markdown = true;
|
|
@@ -581,13 +632,13 @@ function main() {
|
|
|
581
632
|
else if (cmd === 'guards') guards(rest);
|
|
582
633
|
else {
|
|
583
634
|
console.log([
|
|
584
|
-
'PromptWheel —
|
|
635
|
+
'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.',
|
|
585
636
|
'',
|
|
586
637
|
' promptwheel init [--preset <name> | --list] write a starter config for your stack',
|
|
587
638
|
' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
|
|
639
|
+
' catches reward-hacking BY DEFAULT — re-proves each win from SOURCE edits alone (verdict GAMED,',
|
|
640
|
+
' exit 2, when a metric only moved by editing tests/config/grader). Use --no-detect-gaming for the bare gate.',
|
|
588
641
|
' 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)',
|
|
591
642
|
' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
|
|
592
643
|
' exit 0=kept · 1=regression · 3=plateau · add --json',
|
|
593
644
|
' promptwheel insights which metrics actually respond (loop memory)',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.3",
|
|
4
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": {
|