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 +18 -0
- package/bin/promptwheel.mjs +40 -6
- package/package.json +1 -1
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.
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
+
"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": {
|