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 +59 -8
- package/bin/promptwheel.mjs +204 -13
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml) [](LICENSE)  
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Catch your agent cheating** — the deterministic auditor that flags when your AI coding agent gamed its own success metric.
|
|
6
6
|
|
|
7
|
-
PromptWheel
|
|
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
|
-
|
|
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
|
-
|
|
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 #
|
|
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
|
-
- [
|
|
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
|
|
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)**.
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
}
|