promptwheel 0.1.2 → 0.3.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 +16 -9
- package/bin/promptwheel.mjs +219 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,23 +42,23 @@ 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
|
|
48
|
+
npx promptwheel init # guarded tests + antihack tripwires by default
|
|
49
49
|
npx promptwheel run # detection ON by default · exit 0 win · 1 regression · 2 GAMED
|
|
50
50
|
# (add --no-detect-gaming for just the outcome gate)
|
|
51
51
|
```
|
|
52
52
|
|
|
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.
|
|
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).
|
|
54
54
|
|
|
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).
|
|
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/)**.
|
|
56
56
|
|
|
57
57
|
## Use
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
60
|
# 0. write a starter config for your stack (or hand-write promptwheel.config.json)
|
|
61
|
-
npx promptwheel init # detects stack → guarded
|
|
61
|
+
npx promptwheel init # detects stack → guarded tests + antihack tripwires (+ lint if eslint is set up)
|
|
62
62
|
npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval · antihack
|
|
63
63
|
|
|
64
64
|
# measure a change
|
|
@@ -72,8 +72,14 @@ npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
|
|
|
72
72
|
|
|
73
73
|
# what's actually responding in this repo? (aggregates .promptwheel/outcomes.jsonl)
|
|
74
74
|
npx promptwheel insights
|
|
75
|
+
|
|
76
|
+
# EXPERIMENTAL (Phase 5): the earned playbook + where the next attempt should go
|
|
77
|
+
npx promptwheel playbook # decayed, evidence-gated claims distilled from the record
|
|
78
|
+
npx promptwheel suggest # UCB over the lever scores — proven levers vs under-explored arms
|
|
75
79
|
```
|
|
76
80
|
|
|
81
|
+
**The consequence ledger.** git records *what changed*; PromptWheel records *what the change did* — same trust model (local, deterministic, append-only, no server, no LLM in the verdict). `playbook` and `suggest` are pure re-derivations over that ledger: every rendered line was measured by the gate, decays unless re-earned, and stays hidden below an evidence threshold. No compounding claim is made for them until the A/B acceptance test (`bench/compounding-ab.mjs`) passes on real usage data.
|
|
82
|
+
|
|
77
83
|
**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).
|
|
78
84
|
|
|
79
85
|
## Loop patterns
|
|
@@ -142,6 +148,7 @@ The Action runs straight from its own checkout — no npm install, no build. See
|
|
|
142
148
|
- **direction** — `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
|
|
143
149
|
- **guard** — `true` = a *trusted* regression **fails** the gate; `false` = informational.
|
|
144
150
|
- **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.
|
|
151
|
+
- **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`.
|
|
145
152
|
|
|
146
153
|
## Guardrails & inheritance
|
|
147
154
|
|
|
@@ -186,13 +193,13 @@ The accumulated record of **which change-types move which metrics** is the asset
|
|
|
186
193
|
- [docs/VISION.md](docs/VISION.md) — why we pivoted from orchestrator to outcome gate, the thesis, the moat, the open-core model.
|
|
187
194
|
- [docs/ROADMAP.md](docs/ROADMAP.md) — the phased plan and the ship-now/stay-thin guardrails.
|
|
188
195
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the engine works: schemas, extract modes, the trust/noise model.
|
|
189
|
-
- [docs/LEARNING.md](docs/LEARNING.md) — the (research-gated) Phase-5 design:
|
|
196
|
+
- [docs/LEARNING.md](docs/LEARNING.md) — the (research-gated) Phase-5 design: outcome-curated playbook (Agentic Context Engineering, Stanford 2510.04618) + UCB work-discovery.
|
|
190
197
|
- [CLAUDE.md](CLAUDE.md) — the constitution for anyone (human or agent) working in this repo.
|
|
191
198
|
|
|
192
199
|
## Develop
|
|
193
200
|
|
|
194
201
|
```bash
|
|
195
|
-
npm test #
|
|
202
|
+
npm test # the dep-free suite (node:test) — unit + integration, no dependencies
|
|
196
203
|
```
|
|
197
204
|
|
|
198
205
|
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.
|
|
@@ -210,6 +217,6 @@ The engine is one importable file; pure helpers are exported for unit tests, the
|
|
|
210
217
|
- [x] `insights` — reward-stream aggregation (Phase-5 seed)
|
|
211
218
|
- [x] `--detect-gaming` — reward-hack detection: re-prove the win from source edits alone + `antihack` preset
|
|
212
219
|
- [x] npm publish — `promptwheel@0.1.0` (the lead magnet, shipped 2026-06)
|
|
213
|
-
- [
|
|
220
|
+
- [x] outcome-curated learning + UCB work-discovery — `playbook` + `suggest` + the compounding A/B harness (**experimental**; compounding *claims* stay gated on real-data proof — see [docs/LEARNING.md](docs/LEARNING.md))
|
|
214
221
|
|
|
215
|
-
> Status: **published
|
|
222
|
+
> 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
|
];
|
|
@@ -181,6 +188,18 @@ function changedSourcePaths(repo, base, head) {
|
|
|
181
188
|
return { source: all.filter((p) => !isNonSource(p)), nonSource: all.filter(isNonSource) };
|
|
182
189
|
}
|
|
183
190
|
|
|
191
|
+
// coarse change-location fingerprint for the outcome record: top source dirs of the diff
|
|
192
|
+
function subsystemsOf(repo, base, head) {
|
|
193
|
+
try {
|
|
194
|
+
const counts = {};
|
|
195
|
+
for (const p of changedSourcePaths(repo, base, head).source) {
|
|
196
|
+
const d = p.includes('/') ? p.slice(0, p.indexOf('/')) : '(root)';
|
|
197
|
+
counts[d] = (counts[d] || 0) + 1;
|
|
198
|
+
}
|
|
199
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d]) => d);
|
|
200
|
+
} catch { return []; }
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
// measure `metric` in a worktree at `base` with ONLY the source slice of base→head applied.
|
|
185
204
|
function measureSourceOnly(repo, base, head, metric, linkNM, repeat) {
|
|
186
205
|
const { source } = changedSourcePaths(repo, base, head);
|
|
@@ -215,17 +234,18 @@ function gain(m, before, val) {
|
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
// a metric that improved full-diff: does the win survive when only source edits are applied?
|
|
218
|
-
// gamed = source edits alone reproduce
|
|
219
|
-
|
|
237
|
+
// gamed = source edits alone reproduce less than `threshold` of the gain (default 0.5 — the
|
|
238
|
+
// rest came from editing the goalposts). Tune via `gamingThreshold` (config-level or per-metric).
|
|
239
|
+
function judgeGaming(m, srcResult, threshold = 0.5) {
|
|
220
240
|
if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files — only test/config/grader/golden' };
|
|
221
241
|
if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly — inconclusive' };
|
|
222
242
|
const sourceOnly = median(srcResult.samples);
|
|
223
243
|
const full = gain(m, m.before, m.after);
|
|
224
244
|
if (full == null || full <= 0) return { gamed: false, sourceOnly, retained: 1, reason: 'no real improvement to re-prove' };
|
|
225
245
|
const retained = +(gain(m, m.before, sourceOnly) / full).toFixed(3);
|
|
226
|
-
const gamed = retained <
|
|
246
|
+
const gamed = retained < threshold;
|
|
227
247
|
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`
|
|
248
|
+
? `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
249
|
: `${(retained * 100).toFixed(0)}% of the gain survives source-only — the source earned it` };
|
|
230
250
|
}
|
|
231
251
|
|
|
@@ -279,9 +299,31 @@ function selfHeal(repo) {
|
|
|
279
299
|
} catch { /* cleanup must never block the gate */ }
|
|
280
300
|
}
|
|
281
301
|
|
|
302
|
+
// Python editable installs (src-layout `pip install -e .`) can pin imports to the ORIGINAL
|
|
303
|
+
// checkout: the worktree's tests then import your current tree at BOTH refs and every delta
|
|
304
|
+
// reads 0 — the gate is structurally blind on that repo shape. Undetectable from the verdict
|
|
305
|
+
// (it looks like "unchanged"), so detect the editable install itself and warn. Verified by a
|
|
306
|
+
// controlled repro (CHANGELOG 0.2.2).
|
|
307
|
+
function warnEditableInstall(repo) {
|
|
308
|
+
if (!existsSync(join(repo, 'pyproject.toml')) && !existsSync(join(repo, 'setup.py'))) return;
|
|
309
|
+
try {
|
|
310
|
+
const probe = [
|
|
311
|
+
'import site,glob,os,sys',
|
|
312
|
+
'repo=os.path.realpath(sys.argv[1])',
|
|
313
|
+
'sps=set(site.getsitepackages()+[site.getusersitepackages()])',
|
|
314
|
+
'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)]',
|
|
315
|
+
'hit=any(repo in open(f,errors="ignore").read() for f in files)',
|
|
316
|
+
'print("HIT" if hit else "")',
|
|
317
|
+
].join('\n');
|
|
318
|
+
const out = execFileSync('python3', ['-c', probe, repo], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
319
|
+
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.');
|
|
320
|
+
} catch { /* no python / cannot tell — stay quiet */ }
|
|
321
|
+
}
|
|
322
|
+
|
|
282
323
|
// the shared core: measure a change (base→head) and return the structured report
|
|
283
324
|
function gate(repo, opts) {
|
|
284
325
|
selfHeal(repo);
|
|
326
|
+
warnEditableInstall(repo);
|
|
285
327
|
const cfg = loadConfig(repo);
|
|
286
328
|
const repeat = Math.max(1, opts.repeat ?? cfg.repeat ?? 1);
|
|
287
329
|
const linkNM = cfg.linkNodeModules !== false;
|
|
@@ -312,14 +354,22 @@ function gate(repo, opts) {
|
|
|
312
354
|
if (m.status !== 'improved') continue;
|
|
313
355
|
const cm = cfg.metrics.find((c) => c.name === m.name);
|
|
314
356
|
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));
|
|
357
|
+
const j = judgeGaming(m, measureSourceOnly(repo, base, head, cm, linkNM, repeat), cm.gamingThreshold ?? cfg.gamingThreshold ?? 0.5);
|
|
316
358
|
m.gamed = j.gamed; m.sourceOnly = j.sourceOnly; m.retained = j.retained; m.gamingReason = j.reason;
|
|
317
359
|
}
|
|
318
360
|
}
|
|
319
361
|
const failed = metrics.some((m) => m.guard && !m.ok);
|
|
320
362
|
const gamed = metrics.some((m) => m.gamed === true);
|
|
321
363
|
const verdict = failed ? 'fail' : gamed ? 'gamed' : 'pass';
|
|
322
|
-
const report = {
|
|
364
|
+
const report = {
|
|
365
|
+
base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs',
|
|
366
|
+
// learning-substrate fields (Phase 5): cohort segments reliability by environment,
|
|
367
|
+
// label attributes a change-type, subsystems fingerprint WHERE the change landed.
|
|
368
|
+
cohort: process.env.CI ? 'ci' : 'local',
|
|
369
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
370
|
+
subsystems: subsystemsOf(repo, base, head),
|
|
371
|
+
verdict, metrics,
|
|
372
|
+
};
|
|
323
373
|
if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
|
|
324
374
|
return report;
|
|
325
375
|
}
|
|
@@ -327,7 +377,7 @@ function gate(repo, opts) {
|
|
|
327
377
|
function run(argv) {
|
|
328
378
|
const args = parseArgs(argv);
|
|
329
379
|
const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
330
|
-
const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
|
|
380
|
+
const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming, label: args.label });
|
|
331
381
|
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
332
382
|
else if (args.markdown) console.log(renderMarkdown(report));
|
|
333
383
|
else printHuman(report);
|
|
@@ -355,7 +405,7 @@ function improve(argv) {
|
|
|
355
405
|
try { execSync(args.attempt, { cwd: repo, stdio: 'inherit' }); }
|
|
356
406
|
catch (e) { console.error(` (attempt exited ${e.status ?? 1} — gating whatever it changed)`); }
|
|
357
407
|
|
|
358
|
-
const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
|
|
408
|
+
const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming, label: args.label ?? args.attempt });
|
|
359
409
|
const noChange = report.metrics.every((m) => m.delta === 0 || m.delta == null);
|
|
360
410
|
const improvedNames = report.metrics.filter((m) => m.status === 'improved').map((m) => m.name);
|
|
361
411
|
|
|
@@ -389,7 +439,7 @@ function revert(repo) {
|
|
|
389
439
|
}
|
|
390
440
|
|
|
391
441
|
// Phase-5 seed: turn the accumulated reward stream into signal. Thin on purpose —
|
|
392
|
-
// this is the substrate a future
|
|
442
|
+
// this is the substrate a future outcome-curated playbook / UCB work-discovery loop trains on,
|
|
393
443
|
// NOT that loop itself. Just honest aggregation, no model.
|
|
394
444
|
function insights(argv) {
|
|
395
445
|
const args = parseArgs(argv);
|
|
@@ -417,6 +467,121 @@ function insights(argv) {
|
|
|
417
467
|
console.log(' highest-lever metrics are where an agent loop should spend its attempts.\n');
|
|
418
468
|
}
|
|
419
469
|
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// Phase 5 — outcome-curated playbook + UCB work-discovery.
|
|
472
|
+
// Unfrozen 2026-07-02 by explicit founder decision overriding D7 (see docs/LEARNING.md).
|
|
473
|
+
// Design constraints that survive the unfreeze:
|
|
474
|
+
// - PURE VIEW: the append-only ledger (.promptwheel/outcomes.jsonl) is the only store;
|
|
475
|
+
// the playbook is re-derived on every read (decay at read time — no curator state to rot).
|
|
476
|
+
// - EVIDENCE-GATED: a key renders a claim only past MIN_MOVED weighted observations;
|
|
477
|
+
// everything below that is counted, not asserted.
|
|
478
|
+
// - CLAIM-GATED: no public compounding claim until bench/compounding-ab.mjs passes on
|
|
479
|
+
// real usage data. The gate on the CLAIM outlived the gate on the CODE.
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
const HALF_LIFE = 20; // runs — an entry's weight halves every HALF_LIFE runs unless re-earned
|
|
482
|
+
const MIN_MOVED = 3; // weighted moved-observations before a key earns a rendered claim
|
|
483
|
+
const EXPLORE_C = 1.0; // UCB exploration constant
|
|
484
|
+
|
|
485
|
+
function readLedger(repo) {
|
|
486
|
+
const f = join(repo, '.promptwheel', 'outcomes.jsonl');
|
|
487
|
+
if (!existsSync(f)) return [];
|
|
488
|
+
return readFileSync(f, 'utf8').split('\n').filter(Boolean)
|
|
489
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// fold the ledger into decayed per-key stats. keys: `metric` · `metric @ subsystem` · `metric # label`
|
|
493
|
+
function foldOutcomes(runs) {
|
|
494
|
+
const keys = new Map();
|
|
495
|
+
const N = runs.length;
|
|
496
|
+
runs.forEach((r, idx) => {
|
|
497
|
+
const w = Math.pow(0.5, (N - 1 - idx) / HALF_LIFE); // idx N-1 = newest → weight 1
|
|
498
|
+
for (const m of (r.metrics || [])) {
|
|
499
|
+
const tags = [m.name];
|
|
500
|
+
for (const s of (r.subsystems || [])) tags.push(`${m.name} @ ${s}`);
|
|
501
|
+
if (r.label) tags.push(`${m.name} # ${r.label}`);
|
|
502
|
+
for (const key of tags) {
|
|
503
|
+
const k = keys.get(key) ?? { key, metric: m.name, w: 0, n: 0, imp: 0, reg: 0, moved: 0, effects: [], cohorts: {}, last: null };
|
|
504
|
+
k.n += 1; k.w += w;
|
|
505
|
+
const coh = (k.cohorts[r.cohort || 'unknown'] ??= { imp: 0, reg: 0 });
|
|
506
|
+
if (m.status === 'improved') {
|
|
507
|
+
k.imp += w; k.moved += w; coh.imp += 1;
|
|
508
|
+
if (typeof m.delta === 'number' && m.delta !== 0) k.effects.push(Math.abs(m.delta));
|
|
509
|
+
} else if (m.status === 'regressed') { k.reg += w; k.moved += w; coh.reg += 1; }
|
|
510
|
+
k.last = r.ts || k.last;
|
|
511
|
+
keys.set(key, k);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
return [...keys.values()];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const betaMean = (imp, reg) => (1 + imp) / (2 + imp + reg); // Beta(1,1)-smoothed helpfulness
|
|
519
|
+
|
|
520
|
+
// composite lever = smoothed helpfulness × responsiveness; UCB adds an exploration bonus
|
|
521
|
+
function scoreKey(k, totalW) {
|
|
522
|
+
const p = betaMean(k.imp, k.reg);
|
|
523
|
+
const move = k.w ? k.moved / k.w : 0;
|
|
524
|
+
const lever = p * move;
|
|
525
|
+
const ucb = lever + EXPLORE_C * Math.sqrt(Math.log(Math.max(Math.E, totalW)) / Math.max(1e-6, k.w));
|
|
526
|
+
return { p: +p.toFixed(3), move: +move.toFixed(3), lever: +lever.toFixed(3), ucb: +ucb.toFixed(3), effect: +(median(k.effects) ?? 0).toFixed(6) };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// cohorts that disagree in sign are flagged, never averaged into a lie
|
|
530
|
+
function cohortNote(k) {
|
|
531
|
+
const cs = Object.entries(k.cohorts).filter(([, c]) => c.imp + c.reg > 0);
|
|
532
|
+
if (cs.length < 2) return '';
|
|
533
|
+
const signs = new Set(cs.map(([, c]) => Math.sign(c.imp - c.reg)));
|
|
534
|
+
return signs.size > 1 ? ' · ⚠ cohort-dependent (ci vs local disagree)' : '';
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function loadEntries(repo) {
|
|
538
|
+
const runs = readLedger(repo);
|
|
539
|
+
if (!runs.length) { console.error('no outcome record yet — run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
|
|
540
|
+
const keys = foldOutcomes(runs);
|
|
541
|
+
const totalW = keys.filter((k) => k.key === k.metric).reduce((s, k) => s + k.w, 0);
|
|
542
|
+
const entries = keys.map((k) => ({ ...k, ...scoreKey(k, totalW), sufficient: k.moved >= MIN_MOVED }))
|
|
543
|
+
.sort((a, b) => b.lever - a.lever || b.w - a.w);
|
|
544
|
+
return { runs, entries };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// the distilled, execution-earned context an agent (or CLAUDE.md) can consume
|
|
548
|
+
function playbook(argv) {
|
|
549
|
+
const args = parseArgs(argv);
|
|
550
|
+
const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
551
|
+
const { runs, entries } = loadEntries(repo);
|
|
552
|
+
if (args.json) { console.log(JSON.stringify({ runs: runs.length, halfLife: HALF_LIFE, minMoved: MIN_MOVED, entries }, null, 2)); return; }
|
|
553
|
+
const suff = entries.filter((e) => e.sufficient);
|
|
554
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
555
|
+
const out = [];
|
|
556
|
+
out.push(`## Earned playbook — ${runs.length} gated runs, decay half-life ${HALF_LIFE} runs`);
|
|
557
|
+
out.push('*(every line below was measured by the gate, not asserted; entries decay unless re-earned)*', '');
|
|
558
|
+
if (!suff.length) {
|
|
559
|
+
out.push(`_Nothing has earned a claim yet — a key needs ≥${MIN_MOVED} weighted moved-observations. Keep gating; ${entries.length} keys are accumulating._`);
|
|
560
|
+
} else {
|
|
561
|
+
for (const e of suff.slice(0, 20)) {
|
|
562
|
+
out.push(`- **${e.key}** — helpful ${pct(e.p)} when it moves (${e.imp.toFixed(1)}✓/${e.reg.toFixed(1)}✗ weighted), responds in ${pct(e.move)} of runs, median effect ${e.effect}${cohortNote(e)}`);
|
|
563
|
+
}
|
|
564
|
+
const hidden = entries.length - suff.length;
|
|
565
|
+
if (hidden > 0) out.push('', `_${hidden} more keys below the evidence threshold — counted, not asserted._`);
|
|
566
|
+
}
|
|
567
|
+
console.log(out.join('\n'));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// UCB work-discovery: where should the loop spend its NEXT attempt? Proposes measured
|
|
571
|
+
// targets (metric/subsystem arms) — never code advice; the measurement stays the message.
|
|
572
|
+
function suggest(argv) {
|
|
573
|
+
const args = parseArgs(argv);
|
|
574
|
+
const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
575
|
+
const { runs, entries } = loadEntries(repo);
|
|
576
|
+
const arms = entries.filter((e) => e.key === e.metric || e.key.includes(' @ ')).sort((a, b) => b.ucb - a.ucb);
|
|
577
|
+
if (args.json) { console.log(JSON.stringify({ runs: runs.length, thin: runs.length < 10, arms: arms.slice(0, 10) }, null, 2)); return; }
|
|
578
|
+
console.log(`\nPromptWheel suggest — UCB over ${runs.length} gated runs${runs.length < 10 ? ' (thin record: exploration dominates — treat as a coin with opinions)' : ''}\n`);
|
|
579
|
+
for (const a of arms.slice(0, 5)) {
|
|
580
|
+
console.log(` ${a.ucb.toFixed(3)} ${a.key.padEnd(28)} lever ${a.lever.toFixed(3)} (helpful ${a.p}, responds ${a.move}) · evidence ${a.moved.toFixed(1)} moved / ${a.n} runs${a.sufficient ? '' : ' · below claim threshold'}`);
|
|
581
|
+
}
|
|
582
|
+
console.log('\n score = lever + exploration bonus — high scores are either proven levers or under-explored arms.\n');
|
|
583
|
+
}
|
|
584
|
+
|
|
420
585
|
// ---------------------------------------------------------------------------
|
|
421
586
|
// init — write a starter config so a newcomer isn't staring at a blank page
|
|
422
587
|
// ---------------------------------------------------------------------------
|
|
@@ -435,10 +600,10 @@ const PRESETS = {
|
|
|
435
600
|
'antihack': { desc: 'catch reward-hacking: a target + tripwires; pairs with `run --detect-gaming` (source-only re-run)',
|
|
436
601
|
metrics: [
|
|
437
602
|
{ 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 },
|
|
603
|
+
{ 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 },
|
|
604
|
+
{ 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 },
|
|
605
|
+
{ 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 },
|
|
606
|
+
{ 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
607
|
] },
|
|
443
608
|
};
|
|
444
609
|
|
|
@@ -447,10 +612,26 @@ function detectTestCmd(repo) {
|
|
|
447
612
|
if (has('go.mod')) return 'go test ./...';
|
|
448
613
|
if (has('Cargo.toml')) return 'cargo test';
|
|
449
614
|
if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) return 'pytest -q';
|
|
450
|
-
|
|
615
|
+
// only trust `npm test` when a test script actually exists — otherwise the metric can
|
|
616
|
+
// never pass and the gate would "protect" nothing (very common in Next.js app repos)
|
|
617
|
+
try {
|
|
618
|
+
if (JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).scripts?.test) return 'npm test --silent';
|
|
619
|
+
} catch { /* no or unparsable package.json — fall through */ }
|
|
451
620
|
return 'echo "set your test command in promptwheel.config.json" && false';
|
|
452
621
|
}
|
|
453
622
|
|
|
623
|
+
// only offer the lint metric where eslint actually exists — otherwise it reads 0 forever
|
|
624
|
+
// and a newcomer mistakes a metric that can't move for a clean repo.
|
|
625
|
+
function hasEslint(repo) {
|
|
626
|
+
const files = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
|
|
627
|
+
'.eslintrc', '.eslintrc.json', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.yml', '.eslintrc.yaml'];
|
|
628
|
+
if (files.some((f) => existsSync(join(repo, f)))) return true;
|
|
629
|
+
try {
|
|
630
|
+
const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
|
|
631
|
+
return !!(pkg.devDependencies?.eslint || pkg.dependencies?.eslint);
|
|
632
|
+
} catch { return false; }
|
|
633
|
+
}
|
|
634
|
+
|
|
454
635
|
function init(argv) {
|
|
455
636
|
if (argv.includes('--list')) {
|
|
456
637
|
console.log('PromptWheel presets (promptwheel init --preset <name>):\n');
|
|
@@ -472,13 +653,14 @@ function init(argv) {
|
|
|
472
653
|
metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
|
|
473
654
|
note = presetName;
|
|
474
655
|
} else {
|
|
475
|
-
//
|
|
656
|
+
// default = the headline posture: guarded tests + the antihack tripwires, so the FIRST
|
|
657
|
+
// cheat a newcomer tries (gut the tests while the metric stays flat) fails the gate —
|
|
658
|
+
// "catch your agent cheating" must be true out of the box, not only under a preset.
|
|
476
659
|
const testCmd = detectTestCmd(repo);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
note = `tests-pass + lint (detected: ${testCmd})`;
|
|
660
|
+
const lint = hasEslint(repo);
|
|
661
|
+
metrics = PRESETS['antihack'].metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: testCmd } : m);
|
|
662
|
+
if (lint) metrics = [...metrics, { name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }];
|
|
663
|
+
note = `tests + antihack tripwires${lint ? ' + lint' : ''} (detected: ${testCmd})`;
|
|
482
664
|
}
|
|
483
665
|
writeFileSync(out, JSON.stringify({ repeat: 1, metrics }, null, 2) + '\n');
|
|
484
666
|
console.log(`✓ wrote promptwheel.config.json — ${note}`);
|
|
@@ -523,9 +705,12 @@ function printHuman(r) {
|
|
|
523
705
|
const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
|
|
524
706
|
const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
|
|
525
707
|
console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
|
|
708
|
+
if (m.inert) console.log(' ⚠ never passed at either ref — this guard is protecting nothing (check its command)');
|
|
526
709
|
if (m.gamed === true) console.log(` 🚩 GAMED — ${m.gamingReason}`);
|
|
527
710
|
}
|
|
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' : ''}
|
|
711
|
+
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' : ''}`);
|
|
712
|
+
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');
|
|
713
|
+
console.log('');
|
|
529
714
|
}
|
|
530
715
|
|
|
531
716
|
// PR-comment markdown (rendering lives in the tool so the GitHub Action stays thin)
|
|
@@ -534,7 +719,7 @@ function renderMarkdown(r) {
|
|
|
534
719
|
const sIcon = { improved: '🟢', regressed: '🔴', unchanged: '⚪', inconclusive: '🟡', unmeasurable: '⚫' };
|
|
535
720
|
const rows = r.metrics.map((m) => {
|
|
536
721
|
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}`;
|
|
722
|
+
const status = m.gamed === true ? `🚩 gamed (${(m.retained * 100).toFixed(0)}% survives source-only)` : `${sIcon[m.status] || ''} ${m.status}${m.inert ? ' ⚠ never passes' : ''}`;
|
|
538
723
|
return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${status} | ${m.confidence} |`;
|
|
539
724
|
}).join('\n');
|
|
540
725
|
return [
|
|
@@ -563,21 +748,24 @@ function parseArgs(argv) {
|
|
|
563
748
|
else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') a.detectGaming = true;
|
|
564
749
|
else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') a.detectGaming = false;
|
|
565
750
|
else if (argv[i] === '--attempt') a.attempt = argv[++i];
|
|
751
|
+
else if (argv[i] === '--label') a.label = argv[++i];
|
|
566
752
|
else if (argv[i] === '--json') a.json = true;
|
|
567
|
-
else if (argv[i] === '--markdown') a.markdown = true;
|
|
753
|
+
else if (argv[i] === '--markdown' || argv[i] === '--md') a.markdown = true;
|
|
568
754
|
}
|
|
569
755
|
return a;
|
|
570
756
|
}
|
|
571
757
|
|
|
572
758
|
// pure, side-effect-free helpers are exported for unit testing; the CLI below only
|
|
573
759
|
// runs when this file is executed directly (not when imported by the test suite).
|
|
574
|
-
export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming };
|
|
760
|
+
export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming, foldOutcomes, betaMean, scoreKey };
|
|
575
761
|
|
|
576
762
|
function main() {
|
|
577
763
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
578
764
|
if (cmd === 'run') run(rest);
|
|
579
765
|
else if (cmd === 'improve') improve(rest);
|
|
580
766
|
else if (cmd === 'insights') insights(rest);
|
|
767
|
+
else if (cmd === 'playbook') playbook(rest);
|
|
768
|
+
else if (cmd === 'suggest') suggest(rest);
|
|
581
769
|
else if (cmd === 'init') init(rest);
|
|
582
770
|
else if (cmd === 'guards') guards(rest);
|
|
583
771
|
else {
|
|
@@ -591,7 +779,9 @@ function main() {
|
|
|
591
779
|
' promptwheel run --working gate uncommitted changes (incl. newly added files)',
|
|
592
780
|
' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
|
|
593
781
|
' exit 0=kept · 1=regression · 3=plateau · add --json',
|
|
594
|
-
' promptwheel insights which metrics actually respond (
|
|
782
|
+
' promptwheel insights which metrics actually respond (raw counts)',
|
|
783
|
+
' promptwheel playbook [--json] the earned playbook: decayed, evidence-gated claims distilled from the record',
|
|
784
|
+
' promptwheel suggest [--json] UCB work-discovery: where the next attempt should go (experimental)',
|
|
595
785
|
' promptwheel guards show the effective guardrails (incl. inherited) + flag record',
|
|
596
786
|
'',
|
|
597
787
|
'Loop it: while promptwheel improve --attempt "$AGENT"; do :; done # stops on plateau/regression',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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": {
|