promptwheel 0.5.1 → 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 +90 -13
- package/package.json +1 -1
package/bin/promptwheel.mjs
CHANGED
|
@@ -75,6 +75,18 @@ function loadConfig(repo) {
|
|
|
75
75
|
return cfg;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// does the repo have a committed gate config?
|
|
79
|
+
const hasConfigFile = (repo) => ['promptwheel.config.json', 'outcome-gate.config.json'].some((f) => existsSync(join(repo, f)));
|
|
80
|
+
|
|
81
|
+
// the zero-config default: the gate `init` would write, built in memory from stack detection.
|
|
82
|
+
// It gates the detected test command (gaming detection on) with the stack's env/linkDirs so the
|
|
83
|
+
// measurement actually runs in a throwaway worktree. Lets `run` and `check()` work with no config
|
|
84
|
+
// file; `init` is for customizing. A repo with no test command gets a sentinel that reads inert ->
|
|
85
|
+
// INCONCLUSIVE (never a fake green), with a message pointing at the fix.
|
|
86
|
+
function autoConfig(repo) {
|
|
87
|
+
return { ...detectEnv(repo), metrics: [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }] };
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
function resolveBase(repo, base) {
|
|
79
91
|
if (base) return base;
|
|
80
92
|
for (const b of ['origin/main', 'origin/master', 'main', 'master']) {
|
|
@@ -460,6 +472,20 @@ function gateWeakenings(baseGate, headMetrics) {
|
|
|
460
472
|
return weak;
|
|
461
473
|
}
|
|
462
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
|
+
|
|
463
489
|
function gate(repo, opts) {
|
|
464
490
|
selfHeal(repo);
|
|
465
491
|
warnEditableInstall(repo);
|
|
@@ -521,8 +547,21 @@ function gate(repo, opts) {
|
|
|
521
547
|
...(weakened.length ? { weakened } : {}),
|
|
522
548
|
};
|
|
523
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
|
+
}
|
|
524
563
|
const failed = metrics.some((m) => m.guard && !m.ok);
|
|
525
|
-
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);
|
|
526
565
|
// a guard that never runs (inert: a pass/fail metric stuck at 0 across both refs) verifies
|
|
527
566
|
// nothing, don't launder that into a green PASS. The honest verdict is "couldn't measure".
|
|
528
567
|
// BUT scope it to a bare no-op run: an inert guard must NOT bury a genuine, measured win
|
|
@@ -538,6 +577,7 @@ function gate(repo, opts) {
|
|
|
538
577
|
subsystems: subsystemsOf(repo, base, head, cfg),
|
|
539
578
|
verdict, metrics,
|
|
540
579
|
...(gateInfo ? { gate: gateInfo } : {}),
|
|
580
|
+
...(allowed ? { allowed } : {}),
|
|
541
581
|
};
|
|
542
582
|
if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
|
|
543
583
|
return report;
|
|
@@ -546,7 +586,12 @@ function gate(repo, opts) {
|
|
|
546
586
|
function run(argv) {
|
|
547
587
|
const args = parseArgs(argv);
|
|
548
588
|
const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
549
|
-
|
|
589
|
+
let config;
|
|
590
|
+
if (!hasConfigFile(repo) && !args.noAuto) {
|
|
591
|
+
config = autoConfig(repo);
|
|
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)`);
|
|
593
|
+
}
|
|
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 });
|
|
550
595
|
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
551
596
|
else if (args.markdown) console.log(renderMarkdown(report));
|
|
552
597
|
else printHuman(report);
|
|
@@ -841,6 +886,20 @@ function detectTestCmd(repo) {
|
|
|
841
886
|
|
|
842
887
|
// deps that live outside git + the env a test needs, defaulted per stack. init writes these into
|
|
843
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
|
+
|
|
844
903
|
function detectEnv(repo) {
|
|
845
904
|
const has = (f) => existsSync(join(repo, f));
|
|
846
905
|
// Python: link the venv (3rd-party deps) and put the worktree src FIRST on PYTHONPATH, so imports
|
|
@@ -906,7 +965,8 @@ function init(argv) {
|
|
|
906
965
|
if (lint) metrics = [...metrics, { name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }];
|
|
907
966
|
note = `tests + antihack tripwires${lint ? ' + lint' : ''} (detected: ${testCmd})`;
|
|
908
967
|
}
|
|
909
|
-
const
|
|
968
|
+
const setup = presetName ? undefined : detectSetup(repo);
|
|
969
|
+
const envCfg = presetName ? {} : { ...detectEnv(repo), ...(setup ? { setup } : {}) };
|
|
910
970
|
if (envCfg.linkDirs) note += ` · linking ${envCfg.linkDirs.join(', ')}`;
|
|
911
971
|
writeFileSync(out, JSON.stringify({ repeat: 1, ...envCfg, ...(evaluatorPaths ? { evaluatorPaths } : {}), metrics }, null, 2) + '\n');
|
|
912
972
|
console.log(`✓ wrote promptwheel.config.json, ${note}`);
|
|
@@ -951,12 +1011,13 @@ function printHuman(r) {
|
|
|
951
1011
|
const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
|
|
952
1012
|
const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
|
|
953
1013
|
console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
|
|
954
|
-
if (m.inert) console.log(
|
|
1014
|
+
if (m.inert) console.log(` ⚠ "${m.name}" never ran (same value at both refs): its command needs a build/deps first, or is not your real test command. Fix: promptwheel init, or set setup/linkDirs in promptwheel.config.json`);
|
|
955
1015
|
if (m.gamed === true) console.log(` 🚩 GAMED, ${m.gamingReason}`);
|
|
956
1016
|
}
|
|
957
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)`);
|
|
958
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' : ''}`);
|
|
959
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})`);
|
|
960
1021
|
console.log('');
|
|
961
1022
|
}
|
|
962
1023
|
|
|
@@ -993,6 +1054,8 @@ function parseArgs(argv) {
|
|
|
993
1054
|
else if (argv[i] === '--repeat') a.repeat = parseInt(argv[++i], 10);
|
|
994
1055
|
else if (argv[i] === '--working') a.working = true;
|
|
995
1056
|
else if (argv[i] === '--no-record') a.noRecord = true;
|
|
1057
|
+
else if (argv[i] === '--no-auto') a.noAuto = true;
|
|
1058
|
+
else if (argv[i] === '--allow') a.allow = argv[++i];
|
|
996
1059
|
else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') { a.detectGaming = true; a.dgExplicit = true; }
|
|
997
1060
|
else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') { a.detectGaming = false; a.dgExplicit = true; }
|
|
998
1061
|
else if (argv[i] === '--since') a.since = argv[++i];
|
|
@@ -1046,7 +1109,8 @@ export function verdictExitCode(verdict) {
|
|
|
1046
1109
|
// head string head ref (default: HEAD)
|
|
1047
1110
|
// working boolean gate uncommitted changes instead of base..head
|
|
1048
1111
|
// config object a resolved config { metrics:[...], ... }; omit to load
|
|
1049
|
-
// promptwheel.config.json
|
|
1112
|
+
// promptwheel.config.json, or auto-detect a default when none exists
|
|
1113
|
+
// auto boolean allow the zero-config auto-detect fallback (default true; false throws if no config)
|
|
1050
1114
|
// repeat number measurement repeats for the noise band (default: config.repeat ?? 1)
|
|
1051
1115
|
// detectGaming boolean run the Section 3 source-only re-proof (default: true)
|
|
1052
1116
|
// cohort string cohort tag for the record (default: CI ? 'ci' : 'local')
|
|
@@ -1059,23 +1123,25 @@ export function check(options = {}) {
|
|
|
1059
1123
|
: git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
1060
1124
|
} catch { throw new Error(`promptwheel check: not inside a git repository (${options.repo || process.cwd()})`); }
|
|
1061
1125
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
if (!
|
|
1126
|
+
let config = options.config;
|
|
1127
|
+
if (config !== undefined) {
|
|
1128
|
+
if (!config || typeof config !== 'object' || !Array.isArray(config.metrics) || config.metrics.length === 0)
|
|
1065
1129
|
throw new Error('promptwheel check: options.config.metrics must be a non-empty array');
|
|
1066
|
-
} else if (!
|
|
1067
|
-
throw new Error(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config
|
|
1130
|
+
} else if (!hasConfigFile(repo)) {
|
|
1131
|
+
if (options.auto === false) throw new Error(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config, or allow auto-detect)`);
|
|
1132
|
+
config = autoConfig(repo); // zero-config: gate the detected test command in process
|
|
1068
1133
|
}
|
|
1069
1134
|
|
|
1070
1135
|
return gate(repo, {
|
|
1071
1136
|
base: options.base,
|
|
1072
1137
|
head: options.head,
|
|
1073
1138
|
working: options.working,
|
|
1074
|
-
config
|
|
1139
|
+
config,
|
|
1075
1140
|
repeat: options.repeat,
|
|
1076
1141
|
detectGaming: options.detectGaming ?? true,
|
|
1077
1142
|
cohort: options.cohort,
|
|
1078
1143
|
label: options.label,
|
|
1144
|
+
allow: options.allow,
|
|
1079
1145
|
noRecord: options.record !== true,
|
|
1080
1146
|
});
|
|
1081
1147
|
}
|
|
@@ -1117,6 +1183,7 @@ export function conformsToContract(report) {
|
|
|
1117
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)`);
|
|
1118
1184
|
if (m.confidence !== undefined && typeof m.confidence !== 'string') E(`${at}.confidence must be a string`);
|
|
1119
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`);
|
|
1120
1187
|
if (m.sourceOnly !== undefined && !numOrNull(m.sourceOnly)) E(`${at}.sourceOnly must be a number or null`);
|
|
1121
1188
|
if (m.retained !== undefined && !numOrNull(m.retained)) E(`${at}.retained must be a number or null`);
|
|
1122
1189
|
if (m.gamingReason !== undefined && typeof m.gamingReason !== 'string') E(`${at}.gamingReason must be a string`);
|
|
@@ -1132,12 +1199,21 @@ export function conformsToContract(report) {
|
|
|
1132
1199
|
}
|
|
1133
1200
|
}
|
|
1134
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
|
+
|
|
1135
1211
|
// Honest-verdict rules that hold regardless of how a verdict is derived:
|
|
1136
1212
|
// a clean pass cannot hide a failing guard, a gamed win, or a weakened gate; and a gamed
|
|
1137
1213
|
// verdict must point at a gamed metric or a weakened gate. (Fail-vs-gamed precedence is
|
|
1138
1214
|
// left to the implementation.)
|
|
1139
1215
|
if (VERDICTS.includes(report.verdict)) {
|
|
1140
|
-
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);
|
|
1141
1217
|
if (report.verdict === 'pass' && report.metrics.some((m) => m.guard === true && m.ok === false))
|
|
1142
1218
|
E("verdict 'pass' but a guarded metric regressed (guard:true, ok:false)");
|
|
1143
1219
|
if (report.verdict === 'pass' && report.metrics.some((m) => m.gamed === true))
|
|
@@ -1212,11 +1288,12 @@ function main() {
|
|
|
1212
1288
|
console.log([
|
|
1213
1289
|
'PromptWheel, catch your agent cheating. Prove a change moved a real metric (and that the agent earned it, not gamed it). The per-turn reward + source-only audit for AI coding loops.',
|
|
1214
1290
|
'',
|
|
1215
|
-
' promptwheel init [--preset <name> | --list] write a starter config
|
|
1291
|
+
' promptwheel init [--preset <name> | --list] write a starter config (OPTIONAL: run works zero-config, auto-detecting your test command)',
|
|
1216
1292
|
' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
|
|
1217
1293
|
' catches reward-hacking BY DEFAULT, re-proves each win from SOURCE edits alone (verdict GAMED,',
|
|
1218
1294
|
' exit 2, when a metric only moved by editing tests/config/grader). Use --no-detect-gaming for the bare gate.',
|
|
1219
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',
|
|
1220
1297
|
' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
|
|
1221
1298
|
' exit 0=kept · 1=regression · 3=plateau · add --json',
|
|
1222
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": {
|