promptwheel 0.7.3 → 0.7.4

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
@@ -213,6 +213,20 @@ Teams keep one shared base config and have every repo inherit it via `extends`:
213
213
 
214
214
  Read this way, `extends` is **the shared invariants, the "business rules", your agents inherit and are held to**: enforced as *measured guards* (a trusted regression fails the gate / reverts the commit), not advisory text an agent can quietly ignore. (Natural-language conventions belong in `AGENTS.md`; only deterministic, measurable guards belong here.)
215
215
 
216
+ ## Mandates (protected paths)
217
+
218
+ Some rules are not a metric at all, they are a hard line: *never touch `src/auth/**`*, *the migration files are frozen*, *do not edit the billing schema*. Declare those as `mandates`, and any change that touches a protected path **fails the gate before a single metric is read**, however green everything else is:
219
+
220
+ ```json
221
+ // promptwheel.config.json
222
+ { "mandates": [
223
+ { "name": "no-auth-edits", "paths": ["src/auth/**"], "reason": "auth changes go through a dedicated, reviewed PR" }
224
+ ],
225
+ "metrics": [ /* ... */ ] }
226
+ ```
227
+
228
+ A mandate is deterministic (a path match on the diff, zero-LLM), rides at the top of the report, and **inherits through `extends`** so a parent scope's hard rule holds for every repo beneath it. It is folded into the gate fingerprint, so quietly deleting a mandate to pass is itself a tracked, tamper-evident change. This is the line between a contract and a request: `AGENTS.md` can *ask* an agent not to touch auth; a mandate *will not let the change past the gate*.
229
+
216
230
  ## Trust model, the point of the whole thing
217
231
 
218
232
  A number that jumps around between runs is worthless as a signal. PromptWheel won't pretend otherwise:
@@ -229,6 +243,10 @@ The accumulated record of **which change-types move which metrics** is the asset
229
243
  - **vs loop/agent frameworks** (Ralph, GEPA, reward models): PromptWheel is the **execution-grounded reward they lack**, it runs your real suite with zero deps; it does not drive the loop or do test-time search.
230
244
  - **When NOT to use it:** if you only care that tests pass, your base verifier already has you covered. PromptWheel earns its place when you have a **graded numeric metric** beyond pass/fail (eval score, $/run, latency, size) that a change could quietly move.
231
245
 
246
+ ## Portability
247
+
248
+ The core gamed-win check is **git only** and runs anywhere git does: it classifies the files a win touched (`git diff --name-only`) to separate production source from test, config, and grader files, so a green that came only from editing the goalposts is caught the same on Linux, macOS, and Windows (the `gamed-catch` CI job proves this on all three). The starter tripwire metrics that `init` writes (`test_count`, `assertions`, `suppressions`, `skipped_tests`) are `grep` one-liners that assume a GNU-grep-compatible shell, which PromptWheel runs through the system shell (`sh` on Linux/macOS, `cmd` on native Windows). Where grep is absent, those metrics read **unmeasurable**, and an unmeasurable guard **fails closed** (verdict `FAIL`), so a gamed green is still caught, never passed, it just lands as `FAIL` rather than the dedicated `GAMED`. Run under WSL or git-bash, or replace those tripwire `cmd`s with your toolchain's own counters, for a clean verdict on Windows.
249
+
232
250
  ## Docs
233
251
 
234
252
  - [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md), how `--detect-gaming` fits as the **deterministic layer** alongside held-out tests, LLM judges, and human review: the coverage matrix, the compose-as-a-pipeline model, and the honest in/out-of-scope boundary.
@@ -46,19 +46,22 @@ function resolveConfig(p, repo, label, seen) {
46
46
  try {
47
47
  const cfg = readConfigFile(p);
48
48
  const byName = new Map();
49
+ const mandatesByName = new Map(); // hard rules accumulate down the extends chain (a parent's holds)
49
50
  const scalars = {};
50
51
  for (const ref of [].concat(cfg.extends || [])) {
51
52
  const bp = isAbsolute(ref) ? ref : join(dirname(p), ref);
52
53
  if (!existsSync(bp)) { console.error(`extends target not found: ${ref} (from ${relOf(repo, p)})`); process.exit(2); }
53
54
  const base = resolveConfig(bp, repo, relOf(repo, bp), seen);
54
55
  for (const m of base.metrics) byName.set(m.name, m);
56
+ for (const md of (base.mandates || [])) if (md && md.name) mandatesByName.set(md.name, md);
55
57
  for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold', 'evaluatorPaths']) if (base[k] !== undefined) scalars[k] = base[k];
56
58
  }
57
59
  for (const m of (cfg.metrics || [])) {
58
60
  byName.set(m.name, { ...m, __src: label, __override: byName.has(m.name) });
59
61
  }
62
+ for (const md of (cfg.mandates || [])) if (md && md.name) mandatesByName.set(md.name, { ...md, __src: label });
60
63
  for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold', 'evaluatorPaths']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
61
- return { ...scalars, metrics: [...byName.values()] };
64
+ return { ...scalars, metrics: [...byName.values()], mandates: [...mandatesByName.values()] };
62
65
  } finally {
63
66
  seen.delete(abs); // only ACTIVE ancestors are a cycle, a diamond (a base reached two ways) is fine
64
67
  }
@@ -267,6 +270,26 @@ function evaluatorMatcher(cfg) {
267
270
  return (p) => res.some((re) => re.test(p));
268
271
  }
269
272
 
273
+ // mandates: named hard rules that compile to a deterministic gate. A protected-path mandate
274
+ // forbids ANY change to its paths (same glob semantics as evaluatorPaths). A hit fails the gate
275
+ // regardless of metrics and rides at the top of the report; mandates inherit through `extends`,
276
+ // so a parent scope's rule holds for every repo beneath it.
277
+ function checkMandates(repo, base, head, cfg) {
278
+ const mandates = Array.isArray(cfg.mandates) ? cfg.mandates : [];
279
+ if (!mandates.length) return [];
280
+ let changed = [];
281
+ try { changed = git(['diff', '--name-only', base, head], repo).split('\n').filter(Boolean); } catch { return []; }
282
+ const out = [];
283
+ for (const m of mandates) {
284
+ const pats = (Array.isArray(m.paths) ? m.paths : []).filter((s) => typeof s === 'string' && s.trim());
285
+ if (!pats.length) continue;
286
+ const res = pats.map(globToRe);
287
+ const hit = changed.filter((f) => res.some((re) => re.test(f)));
288
+ if (hit.length) out.push({ name: String(m.name || 'mandate'), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: hit.slice(0, 20) });
289
+ }
290
+ return out;
291
+ }
292
+
270
293
  // split the agent's changed files into production-source vs test/config/grader/golden.
271
294
  // `isEval` is the repo's opt-in evaluator-path matcher, treated as non-source alongside
272
295
  // the built-in classifier, so a cheat that only edits a declared gate file is caught.
@@ -298,7 +321,9 @@ function measureSourceOnly(repo, base, head, metric, cfg, repeat) {
298
321
  const env = bridgeEnv(repo, wt, cfg);
299
322
  const patch = git(['diff', base, head, '--', ...source], repo);
300
323
  if (patch.trim()) {
301
- const tryApply = (extra) => { execFileSync('git', ['apply', '--whitespace=nowarn', ...extra], { cwd: wt, input: patch + '\n', encoding: 'utf8' }); };
324
+ // stdio stderr 'ignore': git apply --3way prints "Applied patch ... cleanly" to stderr; that
325
+ // is noise on an otherwise-quiet run (and corrupts --json for callers that fold in stderr).
326
+ const tryApply = (extra) => { execFileSync('git', ['apply', '--whitespace=nowarn', ...extra], { cwd: wt, input: patch + '\n', encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] }); };
302
327
  try { tryApply(['--3way']); }
303
328
  catch { try { tryApply([]); } catch { return { samples: [], hadSourceChange: true, applyFailed: true }; } }
304
329
  }
@@ -454,6 +479,7 @@ function gateFingerprint(metricsList, scalars) {
454
479
  })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
455
480
  gamingThreshold: scalars.gamingThreshold ?? null,
456
481
  evaluatorPaths: Array.isArray(scalars.evaluatorPaths) ? [...scalars.evaluatorPaths].sort() : [],
482
+ mandates: Array.isArray(scalars.mandates) ? scalars.mandates.map((m) => ({ name: String(m.name || ''), paths: [...(Array.isArray(m.paths) ? m.paths : [])].sort() })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) : [],
457
483
  };
458
484
  return createHash('sha256').update(JSON.stringify(norm)).digest('hex').slice(0, 16);
459
485
  }
@@ -616,6 +642,9 @@ function gate(repo, opts) {
616
642
  if (gw) gateInfo.allowed = true;
617
643
  }
618
644
  }
645
+ // mandates run first: a protected-path hard rule fails the gate no matter what the metrics say.
646
+ const mandateViolations = checkMandates(repo, base, head, cfg);
647
+ const mandated = mandateViolations.length > 0;
619
648
  const failed = metrics.some((m) => m.guard && !m.ok);
620
649
  const gamed = metrics.some((m) => m.gamed === true) || !!(gateInfo && gateInfo.weakened && !gateInfo.allowed);
621
650
  // a guard that never runs (inert: a pass/fail metric stuck at 0 across both refs) verifies
@@ -623,7 +652,7 @@ function gate(repo, opts) {
623
652
  // BUT scope it to a bare no-op run: an inert guard must NOT bury a genuine, measured win
624
653
  // elsewhere (that would make `improve` revert good changes and misread them as a plateau).
625
654
  const inconclusive = !failed && !gamed && metrics.some((m) => m.inert) && !metrics.some((m) => m.status === 'improved');
626
- const verdict = failed ? 'fail' : gamed ? 'gamed' : inconclusive ? 'inconclusive' : 'pass';
655
+ const verdict = (mandated || failed) ? 'fail' : gamed ? 'gamed' : inconclusive ? 'inconclusive' : 'pass';
627
656
  const report = {
628
657
  base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs',
629
658
  // learning-substrate fields (Phase 5): cohort segments reliability by environment,
@@ -632,6 +661,7 @@ function gate(repo, opts) {
632
661
  ...(opts.label ? { label: opts.label } : {}),
633
662
  subsystems: subsystemsOf(repo, base, head, cfg),
634
663
  verdict, metrics,
664
+ ...(mandateViolations.length ? { mandates: mandateViolations } : {}),
635
665
  ...(gateInfo ? { gate: gateInfo } : {}),
636
666
  ...(allowed ? { allowed } : {}),
637
667
  };
@@ -1095,6 +1125,7 @@ const short = (repo, ref) => { try { return git(['rev-parse', '--short', ref], r
1095
1125
  function printHuman(r) {
1096
1126
  const arrowFor = (m) => (m.delta == null ? '?' : m.delta > 0 ? '▲' : m.delta < 0 ? '▼' : '=');
1097
1127
  console.log(`\nPromptWheel ${r.base} → ${r.head}${r.repeat > 1 ? ` (×${r.repeat})` : ''}\n`);
1128
+ if (r.mandates) for (const md of r.mandates) console.log(` ⛔ MANDATE "${md.name}" violated${md.reason ? ': ' + md.reason : ''} (touched: ${md.files.join(', ')})`);
1098
1129
  for (const m of r.metrics) {
1099
1130
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
1100
1131
  const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
@@ -1103,8 +1134,10 @@ function printHuman(r) {
1103
1134
  if (m.gamed === true) console.log(` 🚩 GAMED, ${m.gamingReason}`);
1104
1135
  }
1105
1136
  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)`);
1106
- 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' : ''}`);
1107
- 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');
1137
+ const failReason = r.mandates ? ', a mandate was violated (a protected path changed)' : ', a guarded metric regressed (beyond noise)';
1138
+ console.log(`\n VERDICT: ${r.verdict.toUpperCase()}${r.verdict === 'fail' ? failReason : 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' : ''}`);
1139
+ if (r.verdict === 'fail' && r.mandates) console.log(' a mandate is a hard rule; if the change is truly intended, edit the mandate in promptwheel.config.json (a tracked, tamper-evident change), do not work around it');
1140
+ else 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');
1108
1141
  if (r.allowed) console.log(` ✓ allowed: ${r.allowed.cleared.join(', ')} acknowledged as an intentional change, recorded (Sawbuck-Allow: ${r.allowed.reason})`);
1109
1142
  console.log('');
1110
1143
  }
@@ -1123,11 +1156,12 @@ function renderMarkdown(r) {
1123
1156
  '',
1124
1157
  `\`${r.base} → ${r.head}\` · ${r.mode} mode${r.repeat > 1 ? ` · ×${r.repeat}` : ''}`,
1125
1158
  '',
1159
+ ...(r.mandates ? [r.mandates.map((md) => `> ⛔ **Mandate violated:** \`${md.name}\`${md.reason ? ': ' + md.reason : ''} (touched \`${md.files.join('`, `')}\`)`).join('\n'), ''] : []),
1126
1160
  '| metric | before | after | Δ | status | confidence |',
1127
1161
  '|---|--:|--:|--:|---|---|',
1128
1162
  rows,
1129
1163
  '',
1130
- r.verdict === 'gamed' ? '> 🚩 A metric "improved" only because the agent edited tests/config/grader, not the source. The win does not survive a source-only re-run.' : r.verdict === 'fail' ? '> ❌ A 🛡️ guarded metric regressed beyond the noise band.' : r.verdict === 'inconclusive' ? '> 🟡 A 🛡️ guarded check is **inert**, it never runs, so nothing was actually verified. Fix its command before trusting a pass.' : '> ✅ No guarded metric regressed.',
1164
+ r.verdict === 'gamed' ? '> 🚩 A metric "improved" only because the agent edited tests/config/grader, not the source. The win does not survive a source-only re-run.' : r.verdict === 'fail' ? (r.mandates ? '> ⛔ A mandate was violated: a protected path changed. This is a hard rule, enforced before any metric.' : '> ❌ A 🛡️ guarded metric regressed beyond the noise band.') : r.verdict === 'inconclusive' ? '> 🟡 A 🛡️ guarded check is **inert**, it never runs, so nothing was actually verified. Fix its command before trusting a pass.' : '> ✅ No guarded metric regressed.',
1131
1165
  ...(r.gate && r.gate.weakened ? ['', `> 🚩 **Gate weakened:** ${r.gate.weakened.map((w) => `\`${w.name}\` ${w.change === 'guard-removed' ? 'removed' : 'disabled'} (would have ${w.status} at head)`).join('; ')}. The pass was bought by dropping a guard, not by earning it.`] : []),
1132
1166
  '',
1133
1167
  '<sub>🛡️ = guard · _prove every change moved a metric_ · [PromptWheel](https://github.com/promptwheel-ai/promptwheel)</sub>',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
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": {