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 +49 -4
- package/bin/promptwheel.mjs +203 -13
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
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
|
+
|
|
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
|
-
|
|
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 #
|
|
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)**.
|
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,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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
"description": "The
|
|
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
|
}
|