promptwheel 0.6.0 → 0.7.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/bin/promptwheel.mjs +61 -4
- package/package.json +1 -1
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.0",
|
|
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": {
|