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.
@@ -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 envCfg = presetName ? {} : detectEnv(repo);
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.6.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": {