promptwheel 0.5.0 → 0.6.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/README.md CHANGED
@@ -106,6 +106,8 @@ npx promptwheel backfill -n 30 # cold start: seed the record from git
106
106
 
107
107
  **Footprint:** it never touches your working tree, every measurement runs in a throwaway git worktree **in your system temp dir** (one at a time; configured `linkDirs` are symlinked not copied, `node_modules` by default, `.venv` for Python, etc.), removed when the run finishes. The only thing PromptWheel writes to your repo is the optional `.promptwheel/outcomes.jsonl` record, commit it to build the per-repo "what moves what" history, or `.gitignore` it (`--no-record` to skip entirely). A hard-killed run can't leave clutter behind: the next run **self-heals** any orphaned worktree (stale registry entry + abandoned temp checkout).
108
108
 
109
+ **Per-turn cost.** A check runs your metric commands in two throwaway worktrees (before and after) plus a source-only re-run when a metric improved, so one check takes roughly **2 to 3× your metric command's runtime** plus about 40ms of worktree overhead. Point the **per-turn** gate at a *fast* metric (a lint count, a quick test subset, a build-size check) and leave the slow full suite for **CI**: a 50ms check is invisible in a loop, a two-minute one is not. A deterministic metric (a test exit, a count) is accurate at the default `repeat: 1`; only a noisy metric (latency, timing) needs `repeat: 3` or more, which multiplies the cost.
110
+
109
111
  ## Loop patterns
110
112
 
111
113
  PromptWheel is the gate *inside* a loop you don't have to write:
@@ -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']) {
@@ -546,7 +558,12 @@ function gate(repo, opts) {
546
558
  function run(argv) {
547
559
  const args = parseArgs(argv);
548
560
  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 });
561
+ let config;
562
+ if (!hasConfigFile(repo) && !args.noAuto) {
563
+ config = autoConfig(repo);
564
+ 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
+ }
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 });
550
567
  if (args.json) console.log(JSON.stringify(report, null, 2));
551
568
  else if (args.markdown) console.log(renderMarkdown(report));
552
569
  else printHuman(report);
@@ -951,7 +968,7 @@ function printHuman(r) {
951
968
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
952
969
  const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
953
970
  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)');
971
+ 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
972
  if (m.gamed === true) console.log(` 🚩 GAMED, ${m.gamingReason}`);
956
973
  }
957
974
  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)`);
@@ -993,6 +1010,7 @@ function parseArgs(argv) {
993
1010
  else if (argv[i] === '--repeat') a.repeat = parseInt(argv[++i], 10);
994
1011
  else if (argv[i] === '--working') a.working = true;
995
1012
  else if (argv[i] === '--no-record') a.noRecord = true;
1013
+ else if (argv[i] === '--no-auto') a.noAuto = true;
996
1014
  else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') { a.detectGaming = true; a.dgExplicit = true; }
997
1015
  else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') { a.detectGaming = false; a.dgExplicit = true; }
998
1016
  else if (argv[i] === '--since') a.since = argv[++i];
@@ -1046,7 +1064,8 @@ export function verdictExitCode(verdict) {
1046
1064
  // head string head ref (default: HEAD)
1047
1065
  // working boolean gate uncommitted changes instead of base..head
1048
1066
  // config object a resolved config { metrics:[...], ... }; omit to load
1049
- // promptwheel.config.json from the repo
1067
+ // promptwheel.config.json, or auto-detect a default when none exists
1068
+ // auto boolean allow the zero-config auto-detect fallback (default true; false throws if no config)
1050
1069
  // repeat number measurement repeats for the noise band (default: config.repeat ?? 1)
1051
1070
  // detectGaming boolean run the Section 3 source-only re-proof (default: true)
1052
1071
  // cohort string cohort tag for the record (default: CI ? 'ci' : 'local')
@@ -1059,19 +1078,20 @@ export function check(options = {}) {
1059
1078
  : git(['rev-parse', '--show-toplevel'], process.cwd());
1060
1079
  } catch { throw new Error(`promptwheel check: not inside a git repository (${options.repo || process.cwd()})`); }
1061
1080
 
1062
- if (options.config !== undefined) {
1063
- const c = options.config;
1064
- if (!c || typeof c !== 'object' || !Array.isArray(c.metrics) || c.metrics.length === 0)
1081
+ let config = options.config;
1082
+ if (config !== undefined) {
1083
+ if (!config || typeof config !== 'object' || !Array.isArray(config.metrics) || config.metrics.length === 0)
1065
1084
  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)`);
1085
+ } else if (!hasConfigFile(repo)) {
1086
+ if (options.auto === false) throw new Error(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config, or allow auto-detect)`);
1087
+ config = autoConfig(repo); // zero-config: gate the detected test command in process
1068
1088
  }
1069
1089
 
1070
1090
  return gate(repo, {
1071
1091
  base: options.base,
1072
1092
  head: options.head,
1073
1093
  working: options.working,
1074
- config: options.config,
1094
+ config,
1075
1095
  repeat: options.repeat,
1076
1096
  detectGaming: options.detectGaming ?? true,
1077
1097
  cohort: options.cohort,
@@ -1212,7 +1232,7 @@ function main() {
1212
1232
  console.log([
1213
1233
  '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
1234
  '',
1215
- ' promptwheel init [--preset <name> | --list] write a starter config for your stack',
1235
+ ' promptwheel init [--preset <name> | --list] write a starter config (OPTIONAL: run works zero-config, auto-detecting your test command)',
1216
1236
  ' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
1217
1237
  ' catches reward-hacking BY DEFAULT, re-proves each win from SOURCE edits alone (verdict GAMED,',
1218
1238
  ' exit 2, when a metric only moved by editing tests/config/grader). Use --no-detect-gaming for the bare gate.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {