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.
Files changed (2) hide show
  1. package/bin/promptwheel.mjs +90 -13
  2. package/package.json +1 -1
@@ -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
- 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 });
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 envCfg = presetName ? {} : detectEnv(repo);
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(' ⚠ never passed at either ref, this guard is protecting nothing (check its command)');
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 from the repo
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
- if (options.config !== undefined) {
1063
- const c = options.config;
1064
- if (!c || typeof c !== 'object' || !Array.isArray(c.metrics) || c.metrics.length === 0)
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 (!['promptwheel.config.json', 'outcome-gate.config.json'].some((f) => existsSync(join(repo, f)))) {
1067
- throw new Error(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config to supply one in process)`);
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: options.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 for your stack',
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.5.1",
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": {