promptwheel 0.6.0 → 0.7.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 +6 -1
- package/bin/promptwheel.mjs +61 -4
- package/package.json +2 -2
- package/packs/security/scan.mjs +2 -2
package/README.md
CHANGED
|
@@ -75,6 +75,8 @@ npx promptwheel run # detection ON by default · exit 0 wi
|
|
|
75
75
|
|
|
76
76
|
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).
|
|
77
77
|
|
|
78
|
+
**An intentional goalpost change has a sanctioned path.** When you *do* mean to change a test, config, or grader, declare it: a `Sawbuck-Allow: <reason>` trailer on the commit (or `--allow "<reason>"` on the CLI, or the `allow` option on `check()`) turns what would read `GAMED` into a `pass` with the reason recorded in `report.allowed`. An *undeclared* test-only win is still `gamed`, and a reasonless declaration does not override; the check never judges intent, it just forces intent to be explicit and auditable (the reason lives in the commit, so a reviewer sees it instead of a silent green).
|
|
79
|
+
|
|
78
80
|
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/)**.
|
|
79
81
|
|
|
80
82
|
## Use
|
|
@@ -250,5 +252,8 @@ The engine is one importable file; pure helpers are exported for unit tests, the
|
|
|
250
252
|
- [x] `--detect-gaming`, reward-hack detection: re-prove the win from source edits alone + `antihack` preset
|
|
251
253
|
- [x] npm publish, `promptwheel@0.4.2` (the lead magnet)
|
|
252
254
|
- [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))
|
|
255
|
+
- [x] gate integrity: a win that disables a guard or deletes a guarded metric in the same diff reads `GAMED` (SHA-256 gate fingerprint; 0.5.0)
|
|
256
|
+
- [x] zero-config: `run` and `check()` auto-detect the gate with no committed config, `init` becomes optional (0.6.0)
|
|
257
|
+
- [x] declared override (`Sawbuck-Allow:` trailer / `--allow`) + `init` build-setup detection, so a build-gated test measures instead of reading inert (0.7.0)
|
|
253
258
|
|
|
254
|
-
> Status: **published** (npm `promptwheel`, v0.
|
|
259
|
+
> Status: **published** (npm `promptwheel`, v0.7.0), all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -472,6 +472,20 @@ function gateWeakenings(baseGate, headMetrics) {
|
|
|
472
472
|
return weak;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
// A declared, auditable override (CONTRACT Section 3). The head commit can acknowledge an
|
|
476
|
+
// intentional test/config/grader change - an edit, a delete, or a new file - or an intentional
|
|
477
|
+
// gate change, with a trailer: Sawbuck-Allow: <reason>
|
|
478
|
+
// so a change that would read GAMED reads PASS instead, with the acknowledgment RECORDED in
|
|
479
|
+
// report.allowed. The check does not judge intent; it forces intent to be explicit. An UNdeclared
|
|
480
|
+
// test-only win is still gamed. `--allow "<reason>"` (CLI) / `allow` (check) supplies the same for
|
|
481
|
+
// a working-tree or CI run that has no commit message to carry the trailer.
|
|
482
|
+
function readAllowTrailer(repo, ref) {
|
|
483
|
+
let msg = '';
|
|
484
|
+
try { msg = execFileSync('git', ['log', '-1', '--format=%B', ref], { cwd: repo, encoding: 'utf8' }); } catch { return null; }
|
|
485
|
+
const m = msg.match(/^[ \t]*(?:Sawbuck-Allow|Allow-Gamed|Promptwheel-Allow)[ \t]*:[ \t]*(\S.*?)[ \t]*$/im);
|
|
486
|
+
return m && m[1].trim() ? { reason: m[1].trim().slice(0, 300) } : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
475
489
|
function gate(repo, opts) {
|
|
476
490
|
selfHeal(repo);
|
|
477
491
|
warnEditableInstall(repo);
|
|
@@ -533,8 +547,21 @@ function gate(repo, opts) {
|
|
|
533
547
|
...(weakened.length ? { weakened } : {}),
|
|
534
548
|
};
|
|
535
549
|
}
|
|
550
|
+
// declared override: a Sawbuck-Allow trailer (or --allow) acknowledges an intentional
|
|
551
|
+
// goalpost/gate change, so a would-be GAMED becomes PASS, recorded in report.allowed for review.
|
|
552
|
+
const allow = opts.allow ? { reason: String(opts.allow).slice(0, 300) } : (opts.working ? null : readAllowTrailer(repo, head));
|
|
553
|
+
let allowed = null;
|
|
554
|
+
if (opts.detectGaming && allow) {
|
|
555
|
+
const gm = metrics.filter((m) => m.gamed === true);
|
|
556
|
+
const gw = !!(gateInfo && gateInfo.weakened);
|
|
557
|
+
if (gm.length || gw) {
|
|
558
|
+
allowed = { reason: allow.reason, cleared: [...gm.map((m) => m.name), ...(gw ? ['gate'] : [])] };
|
|
559
|
+
for (const m of gm) { m.gamed = false; m.allowed = true; }
|
|
560
|
+
if (gw) gateInfo.allowed = true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
536
563
|
const failed = metrics.some((m) => m.guard && !m.ok);
|
|
537
|
-
const gamed = metrics.some((m) => m.gamed === true) || !!(gateInfo && gateInfo.weakened);
|
|
564
|
+
const gamed = metrics.some((m) => m.gamed === true) || !!(gateInfo && gateInfo.weakened && !gateInfo.allowed);
|
|
538
565
|
// a guard that never runs (inert: a pass/fail metric stuck at 0 across both refs) verifies
|
|
539
566
|
// nothing, don't launder that into a green PASS. The honest verdict is "couldn't measure".
|
|
540
567
|
// BUT scope it to a bare no-op run: an inert guard must NOT bury a genuine, measured win
|
|
@@ -550,6 +577,7 @@ function gate(repo, opts) {
|
|
|
550
577
|
subsystems: subsystemsOf(repo, base, head, cfg),
|
|
551
578
|
verdict, metrics,
|
|
552
579
|
...(gateInfo ? { gate: gateInfo } : {}),
|
|
580
|
+
...(allowed ? { allowed } : {}),
|
|
553
581
|
};
|
|
554
582
|
if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
|
|
555
583
|
return report;
|
|
@@ -563,7 +591,7 @@ function run(argv) {
|
|
|
563
591
|
config = autoConfig(repo);
|
|
564
592
|
console.error(`no promptwheel.config.json, auto-gating the detected test command: ${config.metrics[0].cmd} (promptwheel init to customize; --no-auto to require a config)`);
|
|
565
593
|
}
|
|
566
|
-
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, config });
|
|
594
|
+
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, allow: args.allow, config });
|
|
567
595
|
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
568
596
|
else if (args.markdown) console.log(renderMarkdown(report));
|
|
569
597
|
else printHuman(report);
|
|
@@ -858,6 +886,20 @@ function detectTestCmd(repo) {
|
|
|
858
886
|
|
|
859
887
|
// deps that live outside git + the env a test needs, defaulted per stack. init writes these into
|
|
860
888
|
// config; the measurement bridge (bridgeEnv/runSetup) consumes them, keeps the engine agnostic.
|
|
889
|
+
// a per-ref build/install so the MEASURED worktree is actually runnable. Closes the "inert" class
|
|
890
|
+
// where the detected test command cannot run without a build first (a 10-repo replay put this at
|
|
891
|
+
// ~2/3 of gates). Written by `init` into `setup`; a failed setup stays honest (inert -> inconclusive,
|
|
892
|
+
// never a fake green). Kept out of the zero-config default so the fast per-turn path stays fast.
|
|
893
|
+
function detectSetup(repo) {
|
|
894
|
+
const has = (f) => existsSync(join(repo, f));
|
|
895
|
+
try {
|
|
896
|
+
const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
|
|
897
|
+
if (pkg.scripts && pkg.scripts.build) return 'npm run build --if-present'; // TS/bundler tests that run against a built dir
|
|
898
|
+
} catch { /* no or unparsable package.json */ }
|
|
899
|
+
if (has('pyproject.toml') || has('setup.py')) return 'pip install -e . --quiet 2>/dev/null || true'; // editable install so imports resolve the measured ref
|
|
900
|
+
return undefined;
|
|
901
|
+
}
|
|
902
|
+
|
|
861
903
|
function detectEnv(repo) {
|
|
862
904
|
const has = (f) => existsSync(join(repo, f));
|
|
863
905
|
// Python: link the venv (3rd-party deps) and put the worktree src FIRST on PYTHONPATH, so imports
|
|
@@ -923,7 +965,8 @@ function init(argv) {
|
|
|
923
965
|
if (lint) metrics = [...metrics, { name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }];
|
|
924
966
|
note = `tests + antihack tripwires${lint ? ' + lint' : ''} (detected: ${testCmd})`;
|
|
925
967
|
}
|
|
926
|
-
const
|
|
968
|
+
const setup = presetName ? undefined : detectSetup(repo);
|
|
969
|
+
const envCfg = presetName ? {} : { ...detectEnv(repo), ...(setup ? { setup } : {}) };
|
|
927
970
|
if (envCfg.linkDirs) note += ` · linking ${envCfg.linkDirs.join(', ')}`;
|
|
928
971
|
writeFileSync(out, JSON.stringify({ repeat: 1, ...envCfg, ...(evaluatorPaths ? { evaluatorPaths } : {}), metrics }, null, 2) + '\n');
|
|
929
972
|
console.log(`✓ wrote promptwheel.config.json, ${note}`);
|
|
@@ -974,6 +1017,7 @@ function printHuman(r) {
|
|
|
974
1017
|
if (r.gate && r.gate.weakened) for (const w of r.gate.weakened) console.log(` 🚩 GATE WEAKENED, the guard "${w.name}" was ${w.change === 'guard-removed' ? 'removed' : 'disabled'} in this change and it would have ${w.status} at head (goalpost moved, not earned)`);
|
|
975
1018
|
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' : r.verdict === 'inconclusive' ? ', a guard is inert (measured nothing); a pass cannot be certified' : ''}`);
|
|
976
1019
|
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');
|
|
1020
|
+
if (r.allowed) console.log(` ✓ allowed: ${r.allowed.cleared.join(', ')} acknowledged as an intentional change, recorded (Sawbuck-Allow: ${r.allowed.reason})`);
|
|
977
1021
|
console.log('');
|
|
978
1022
|
}
|
|
979
1023
|
|
|
@@ -1011,6 +1055,7 @@ function parseArgs(argv) {
|
|
|
1011
1055
|
else if (argv[i] === '--working') a.working = true;
|
|
1012
1056
|
else if (argv[i] === '--no-record') a.noRecord = true;
|
|
1013
1057
|
else if (argv[i] === '--no-auto') a.noAuto = true;
|
|
1058
|
+
else if (argv[i] === '--allow') a.allow = argv[++i];
|
|
1014
1059
|
else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') { a.detectGaming = true; a.dgExplicit = true; }
|
|
1015
1060
|
else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') { a.detectGaming = false; a.dgExplicit = true; }
|
|
1016
1061
|
else if (argv[i] === '--since') a.since = argv[++i];
|
|
@@ -1096,6 +1141,7 @@ export function check(options = {}) {
|
|
|
1096
1141
|
detectGaming: options.detectGaming ?? true,
|
|
1097
1142
|
cohort: options.cohort,
|
|
1098
1143
|
label: options.label,
|
|
1144
|
+
allow: options.allow,
|
|
1099
1145
|
noRecord: options.record !== true,
|
|
1100
1146
|
});
|
|
1101
1147
|
}
|
|
@@ -1137,6 +1183,7 @@ export function conformsToContract(report) {
|
|
|
1137
1183
|
if (m.noise !== undefined && !(isNum(m.noise) && m.noise >= 0)) E(`${at}.noise must be a number >= 0 (the measured jitter band; repeat count is the top-level report.repeat)`);
|
|
1138
1184
|
if (m.confidence !== undefined && typeof m.confidence !== 'string') E(`${at}.confidence must be a string`);
|
|
1139
1185
|
if (m.gamed !== undefined && typeof m.gamed !== 'boolean') E(`${at}.gamed must be a boolean`);
|
|
1186
|
+
if (m.allowed !== undefined && typeof m.allowed !== 'boolean') E(`${at}.allowed must be a boolean`);
|
|
1140
1187
|
if (m.sourceOnly !== undefined && !numOrNull(m.sourceOnly)) E(`${at}.sourceOnly must be a number or null`);
|
|
1141
1188
|
if (m.retained !== undefined && !numOrNull(m.retained)) E(`${at}.retained must be a number or null`);
|
|
1142
1189
|
if (m.gamingReason !== undefined && typeof m.gamingReason !== 'string') E(`${at}.gamingReason must be a string`);
|
|
@@ -1152,12 +1199,21 @@ export function conformsToContract(report) {
|
|
|
1152
1199
|
}
|
|
1153
1200
|
}
|
|
1154
1201
|
|
|
1202
|
+
// the optional declared-override record (report.allowed): a reason and the names it cleared.
|
|
1203
|
+
if (report.allowed !== undefined) {
|
|
1204
|
+
if (!report.allowed || typeof report.allowed !== 'object' || Array.isArray(report.allowed)) E('allowed must be an object { reason, cleared }');
|
|
1205
|
+
else {
|
|
1206
|
+
if (typeof report.allowed.reason !== 'string' || !report.allowed.reason) E('allowed.reason must be a non-empty string');
|
|
1207
|
+
if (report.allowed.cleared !== undefined && !Array.isArray(report.allowed.cleared)) E('allowed.cleared must be an array');
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1155
1211
|
// Honest-verdict rules that hold regardless of how a verdict is derived:
|
|
1156
1212
|
// a clean pass cannot hide a failing guard, a gamed win, or a weakened gate; and a gamed
|
|
1157
1213
|
// verdict must point at a gamed metric or a weakened gate. (Fail-vs-gamed precedence is
|
|
1158
1214
|
// left to the implementation.)
|
|
1159
1215
|
if (VERDICTS.includes(report.verdict)) {
|
|
1160
|
-
const gateWeakened = !!(report.gate && Array.isArray(report.gate.weakened) && report.gate.weakened.length);
|
|
1216
|
+
const gateWeakened = !!(report.gate && Array.isArray(report.gate.weakened) && report.gate.weakened.length && !report.gate.allowed);
|
|
1161
1217
|
if (report.verdict === 'pass' && report.metrics.some((m) => m.guard === true && m.ok === false))
|
|
1162
1218
|
E("verdict 'pass' but a guarded metric regressed (guard:true, ok:false)");
|
|
1163
1219
|
if (report.verdict === 'pass' && report.metrics.some((m) => m.gamed === true))
|
|
@@ -1237,6 +1293,7 @@ function main() {
|
|
|
1237
1293
|
' catches reward-hacking BY DEFAULT, re-proves each win from SOURCE edits alone (verdict GAMED,',
|
|
1238
1294
|
' exit 2, when a metric only moved by editing tests/config/grader). Use --no-detect-gaming for the bare gate.',
|
|
1239
1295
|
' promptwheel run --working gate uncommitted changes (incl. newly added files)',
|
|
1296
|
+
' promptwheel run --allow "<reason>" acknowledge an intentional test/config change (or a `Sawbuck-Allow: <reason>` commit trailer): recorded, not flagged GAMED',
|
|
1240
1297
|
' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
|
|
1241
1298
|
' exit 0=kept · 1=regression · 3=plateau · add --json',
|
|
1242
1299
|
' promptwheel insights which metrics actually respond (raw counts)',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "A deterministic, zero-LLM check for your coding loop. It proves every change earned its win by re-running the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook, or import { check } in process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,4 +50,4 @@
|
|
|
50
50
|
"detect-gaming",
|
|
51
51
|
"reward-hack-detection"
|
|
52
52
|
]
|
|
53
|
-
}
|
|
53
|
+
}
|
package/packs/security/scan.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// The full AST+dataflow engine (the other 75 recommended patterns) is the follow-up.
|
|
6
6
|
//
|
|
7
7
|
// Usage: node scan.mjs [path] → prints the finding count (for `promptwheel` metrics)
|
|
8
|
-
// node scan.mjs [path] --list → prints each finding (file:line
|
|
8
|
+
// node scan.mjs [path] --list → prints each finding (file:line · id · message)
|
|
9
9
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join, extname } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
@@ -50,7 +50,7 @@ for (const file of walk(ROOT)) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (LIST) {
|
|
53
|
-
for (const f of findings) console.error(` [${f.sev}] ${f.file}:${f.line}
|
|
53
|
+
for (const f of findings) console.error(` [${f.sev}] ${f.file}:${f.line} · ${f.id} · ${f.msg}`);
|
|
54
54
|
console.error(`\n${findings.length} security-invariant violation(s)`);
|
|
55
55
|
}
|
|
56
56
|
// the metric value: lower is better, 0 is clean
|