promptwheel 0.7.9 → 0.7.10
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/INTEGRATE.md +2 -2
- package/README.md +1 -1
- package/bin/promptwheel.d.ts +4 -4
- package/bin/promptwheel.mjs +41 -9
- package/package.json +1 -1
package/INTEGRATE.md
CHANGED
|
@@ -65,7 +65,7 @@ Fixed vocabularies (use exactly these):
|
|
|
65
65
|
`noise` is the measured jitter band, a single number, across the top-level `repeat` runs; a
|
|
66
66
|
delta inside it is `inconclusive`, never a regression. `retained` is the fraction of a
|
|
67
67
|
metric's gain that survived the source-only re-run (Section 3). `confidence`
|
|
68
|
-
(`unverified | low | medium | high`) and `sourceOnly` are informational fields this
|
|
68
|
+
(`none | unverified | low | medium | high`, `none` for an unmeasurable metric) and `sourceOnly` are informational fields this
|
|
69
69
|
implementation adds; a minimal conforming report may omit them. `gate` (optional) records a
|
|
70
70
|
truncated SHA-256 fingerprint of the gate definition (metrics, guards, `evaluatorPaths`,
|
|
71
71
|
`gamingThreshold`, `mandates`) at base and head, sets `gate.changed: true` when they differ,
|
|
@@ -134,7 +134,7 @@ Returns the Section 2 verdict object (identical to `run --json`). Reproducible f
|
|
|
134
134
|
| `auto` | boolean | `true` | with no config file and no `config` option, gate the auto-detected test command (zero-config); `false` throws instead |
|
|
135
135
|
| `repeat` | number | `config.repeat ?? 1` | measurement repeats for the noise band |
|
|
136
136
|
| `detectGaming` | boolean | `true` | run the Section 3 source-only re-proof |
|
|
137
|
-
| `allow` | string | none | a declared override reason (the same as `--allow` / a `Promptwheel-Allow:` trailer): a would-be `gamed` reads `pass` with the reason recorded in `report.allowed` |
|
|
137
|
+
| `allow` | string | none | a declared override reason (the same as `--allow` / a `Promptwheel-Allow:` trailer): a would-be `gamed`, or an `unprovable` win (its source-only re-run could not be applied), reads `pass` with the reason recorded in `report.allowed` |
|
|
138
138
|
| `record` | boolean | `false` | append the run to `.promptwheel/outcomes.jsonl` |
|
|
139
139
|
|
|
140
140
|
Preconditions throw (they do not exit the host): not inside a git repository; `auto: false`
|
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ Then `/promptwheel:setup` (write a config) · `/promptwheel:gate` (gate uncommit
|
|
|
169
169
|
|
|
170
170
|
- **Tripwire guards** (`test_count`, `skipped_tests`, `suppressions`, `assertions`, written by the plain `init` default) catch the quieter shapes: an inline suppression (`@ts-nocheck`, `eslint-disable`, `# noqa`) or a suite gutted while the metric stays flat.
|
|
171
171
|
- **Gate integrity.** The gate is resolved as of the base commit too. A change that disables a guard or deletes a guarded metric in the same diff it passes is re-measured under the base definition and reads `GAMED` if that guard would have failed. The gate definition (metrics, guards, `evaluatorPaths`, `gamingThreshold`, `mandates`) is fingerprinted at both refs; a difference is recorded as `gate.changed` and printed.
|
|
172
|
-
- **A sanctioned override.** When you *do* mean to change a test, config, or grader, declare it: a `Promptwheel-Allow: <reason>` trailer on the commit (`Sawbuck-Allow:` is accepted as an alias), `--allow "<reason>"` on the CLI, or the `allow` option on `check()`, turns what would read `GAMED` into a `pass` with the reason recorded in `report.allowed`. An undeclared test-only win is still `gamed`, and a reasonless declaration does not override. The check never judges intent; it forces intent to be explicit and auditable.
|
|
172
|
+
- **A sanctioned override.** When you *do* mean to change a test, config, or grader, declare it: a `Promptwheel-Allow: <reason>` trailer on the commit (`Sawbuck-Allow:` is accepted as an alias), `--allow "<reason>"` on the CLI, or the `allow` option on `check()`, turns what would read `GAMED` into a `pass` with the reason recorded in `report.allowed`. It covers a win whose source-only re-run could not be applied (`unprovable`, otherwise `inconclusive`) the same way. An undeclared test-only win is still `gamed`, and a reasonless declaration does not override. The check never judges intent; it forces intent to be explicit and auditable.
|
|
173
173
|
- **A provenance receipt** on every verdict (`report.provenance`: base and head SHAs, a config hash, a verdict hash), so a skeptic can re-run `(base, head, config)` and get the same hash.
|
|
174
174
|
|
|
175
175
|
**The honest limit.** This is one layer, not a silver bullet. It catches the evaluator-tampering class (test, grader, golden and config edits) deterministically and for free. It is blind, by construction, to a cheat that lives in the source file itself (a hardcoded answer, behavior that differs under test): that needs held-out tests, an LLM judge, or a human, and those expensive layers should be reserved for the calls only they can make. The coverage matrix and the in/out-of-scope boundary are in [docs/DETECTION-LAYERS.md](docs/DETECTION-LAYERS.md); the measured numbers are in [bench/RESULTS.md](bench/RESULTS.md) (`node bench/gaming-bench.mjs` to reproduce). Another gameable thing, an LLM judge asking "did you cheat?", is deliberately not part of the check.
|
package/bin/promptwheel.d.ts
CHANGED
|
@@ -108,7 +108,7 @@ export interface CheckOptions {
|
|
|
108
108
|
repeat?: number;
|
|
109
109
|
/** Run the Section 3 source-only re-proof. Default `true`. */
|
|
110
110
|
detectGaming?: boolean;
|
|
111
|
-
/** A declared override reason (the same as `--allow` or a `Promptwheel-Allow:` commit trailer): a would-be `gamed` reads `pass`, recorded in `report.allowed`. */
|
|
111
|
+
/** A declared override reason (the same as `--allow` or a `Promptwheel-Allow:` commit trailer): a would-be `gamed`, or an `unprovable` win (its source-only re-run could not be applied), reads `pass`, recorded in `report.allowed`. */
|
|
112
112
|
allow?: string;
|
|
113
113
|
/** Append the run to `.promptwheel/outcomes.jsonl`. Default `false`. */
|
|
114
114
|
record?: boolean;
|
|
@@ -140,14 +140,14 @@ export interface MetricResult extends Evaluation {
|
|
|
140
140
|
guard: boolean;
|
|
141
141
|
/** `true` when the win did not survive the source-only re-run; `null` when the source-only patch could not be applied (then `unprovable` is set and the verdict is `inconclusive`, never `pass`). Absent without gaming detection or when the metric did not improve. */
|
|
142
142
|
gamed?: boolean | null;
|
|
143
|
-
/**
|
|
144
|
-
unprovable?:
|
|
143
|
+
/** `true`: the source-only patch could not be applied, so this win could not be re-proven and the verdict is `inconclusive`. `false` only after a declared override cleared it (then `allowed` is `true`). */
|
|
144
|
+
unprovable?: boolean;
|
|
145
145
|
/** The metric's value with only the source edits applied. */
|
|
146
146
|
sourceOnly?: number | null;
|
|
147
147
|
/** The fraction of the gain that survived source-only (0..1). */
|
|
148
148
|
retained?: number | null;
|
|
149
149
|
gamingReason?: string;
|
|
150
|
-
/** Set when a declared override cleared a `gamed` on this metric. */
|
|
150
|
+
/** Set when a declared override cleared a `gamed` or an `unprovable` on this metric. */
|
|
151
151
|
allowed?: true;
|
|
152
152
|
}
|
|
153
153
|
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -47,7 +47,8 @@ function bad(msg) { const e = new Error(msg); e.name = 'ConfigError'; e.exitCode
|
|
|
47
47
|
|
|
48
48
|
// the repository root, or a ConfigError: a non-repo cwd must exit 64, never 1 (the FAIL verdict code)
|
|
49
49
|
function repoRoot(cwd) {
|
|
50
|
-
|
|
50
|
+
// stderr ignored: git's own "fatal: not a git repository" must not print above the ConfigError line
|
|
51
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
|
|
51
52
|
catch { bad(`promptwheel: not inside a git repository (${cwd})`); }
|
|
52
53
|
}
|
|
53
54
|
// a ref the gate is about to check out must be in this clone; the usual miss is a shallow CI
|
|
@@ -448,7 +449,9 @@ function workingSnapshot(repo) {
|
|
|
448
449
|
// self-heal: a hard-killed run (SIGKILL / power loss) can leave an orphaned worktree,
|
|
449
450
|
// its `finally` never ran. On the next run, drop stale worktree registry entries and any
|
|
450
451
|
// abandoned /tmp checkout, so nothing accumulates in your repo or temp dir. Never touches a
|
|
451
|
-
// live worktree (a still-registered dir, or
|
|
452
|
+
// live worktree (a still-registered dir, or this repo's checkout younger than 1h). A dir tagged
|
|
453
|
+
// for ANOTHER repo may be mid-measurement, so it gets a full day; after that it is an orphan of a
|
|
454
|
+
// repo that was deleted or moved (a new real path is a new tag nobody will ever claim).
|
|
452
455
|
function selfHeal(repo) {
|
|
453
456
|
try {
|
|
454
457
|
git(['worktree', 'prune'], repo); // remove registry entries whose dir is already gone (always safe)
|
|
@@ -461,8 +464,8 @@ function selfHeal(repo) {
|
|
|
461
464
|
for (const name of readdirSync(tmp)) {
|
|
462
465
|
if (!name.startsWith('promptwheel-') && !name.startsWith('pw-idx-')) continue; // worktrees + temp-index dirs
|
|
463
466
|
const tag = (name.match(TAGGED) || [])[2] || null;
|
|
464
|
-
|
|
465
|
-
const maxAge = tag ? 3600_000 : 86_400_000;
|
|
467
|
+
// ours after an hour; another repo's, or an untagged dir (an older engine's), only after a full day
|
|
468
|
+
const maxAge = tag === mine ? 3600_000 : 86_400_000;
|
|
466
469
|
const p = join(tmp, name);
|
|
467
470
|
if (tracked.has(p)) continue; // a live/registered worktree, leave it
|
|
468
471
|
try { if (statSync(p).mtimeMs < now - maxAge) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
|
|
@@ -725,14 +728,20 @@ function gate(repo, opts) {
|
|
|
725
728
|
const gateInfo = opts.detectGaming ? detectGamingPass(repo, base, head, cfg, metrics, repeat) : null;
|
|
726
729
|
// declared override: a Promptwheel-Allow trailer (or --allow) acknowledges an intentional
|
|
727
730
|
// goalpost/gate change, so a would-be GAMED becomes PASS, recorded in report.allowed for review.
|
|
731
|
+
// It covers an `unprovable` win too (the source-only re-run could not be applied): the declared
|
|
732
|
+
// intent is exactly the uncertainty that verdict expresses, and without this a declared change
|
|
733
|
+
// whose source slice will not re-apply had no override short of turning the check off.
|
|
728
734
|
const allow = opts.allow ? { reason: String(opts.allow).slice(0, 300) } : (opts.working ? null : readAllowTrailer(repo, head));
|
|
729
735
|
let allowed = null;
|
|
730
736
|
if (opts.detectGaming && allow) {
|
|
731
737
|
const gm = metrics.filter((m) => m.gamed === true);
|
|
738
|
+
const up = metrics.filter((m) => m.unprovable === true);
|
|
732
739
|
const gw = !!(gateInfo && gateInfo.weakened);
|
|
733
|
-
if (gm.length || gw) {
|
|
734
|
-
|
|
740
|
+
if (gm.length || up.length || gw) {
|
|
741
|
+
const names = [...new Set([...gm, ...up].map((m) => m.name))];
|
|
742
|
+
allowed = { reason: allow.reason, cleared: [...names, ...(gw ? ['gate'] : [])] };
|
|
735
743
|
for (const m of gm) { m.gamed = false; m.allowed = true; }
|
|
744
|
+
for (const m of up) { m.unprovable = false; m.allowed = true; }
|
|
736
745
|
if (gw) gateInfo.allowed = true;
|
|
737
746
|
}
|
|
738
747
|
}
|
|
@@ -1257,7 +1266,8 @@ export function conformsToContract(report) {
|
|
|
1257
1266
|
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)`);
|
|
1258
1267
|
if (m.confidence !== undefined && typeof m.confidence !== 'string') E(`${at}.confidence must be a string`);
|
|
1259
1268
|
if (m.gamed !== undefined && m.gamed !== null && typeof m.gamed !== 'boolean') E(`${at}.gamed must be a boolean, or null when the win could not be re-proven`);
|
|
1260
|
-
if (m.unprovable !== undefined && m.unprovable !==
|
|
1269
|
+
if (m.unprovable !== undefined && typeof m.unprovable !== 'boolean') E(`${at}.unprovable must be a boolean when present`);
|
|
1270
|
+
if (m.unprovable === false && m.allowed !== true) E(`${at}.unprovable is false but no declared override cleared it (allowed must be true)`);
|
|
1261
1271
|
if (m.allowed !== undefined && typeof m.allowed !== 'boolean') E(`${at}.allowed must be a boolean`);
|
|
1262
1272
|
if (m.sourceOnly !== undefined && !numOrNull(m.sourceOnly)) E(`${at}.sourceOnly must be a number or null`);
|
|
1263
1273
|
if (m.retained !== undefined && !numOrNull(m.retained)) E(`${at}.retained must be a number or null`);
|
|
@@ -1351,9 +1361,31 @@ function conformance(argv) {
|
|
|
1351
1361
|
process.exit(1);
|
|
1352
1362
|
}
|
|
1353
1363
|
|
|
1364
|
+
// the flags each command accepts. An unknown --flag is a usage error (exit 64), never silently
|
|
1365
|
+
// ignored: a typo in a CI step must not turn `--no-detect-gaming` into the default gate or `--allow`
|
|
1366
|
+
// into nothing, and INTEGRATE.md promises a bad flag exits 64. A flag that takes a value consumes
|
|
1367
|
+
// the next argument; anything not starting with `-` (or a bare `-`, stdin for conformance) is positional.
|
|
1368
|
+
const GATE_FLAGS = ['--base', '--head', '--repeat', '--working', '--no-record', '--no-auto', '--allow', '--detect-gaming', '--no-detect-gaming', '--json', '--markdown'];
|
|
1369
|
+
const COMMAND_FLAGS = {
|
|
1370
|
+
run: GATE_FLAGS, improve: [...GATE_FLAGS, '--attempt'], insights: ['--json'], guards: ['--json'],
|
|
1371
|
+
init: ['--preset', '--list', '--force', '--no-agent-block'], conformance: ['--cmd'],
|
|
1372
|
+
};
|
|
1373
|
+
const VALUED_FLAGS = new Set(['--base', '--head', '--repeat', '--allow', '--attempt', '--preset', '--cmd']);
|
|
1374
|
+
function checkFlags(cmd, argv) {
|
|
1375
|
+
const known = new Set(COMMAND_FLAGS[cmd]);
|
|
1376
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1377
|
+
const a = argv[i];
|
|
1378
|
+
if (a === '-' || !a.startsWith('-')) continue;
|
|
1379
|
+
if (!known.has(a)) bad(`promptwheel ${cmd}: unknown flag ${a} (see promptwheel --help)`);
|
|
1380
|
+
if (VALUED_FLAGS.has(a)) i++;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1354
1384
|
function dispatch() {
|
|
1355
|
-
const [
|
|
1356
|
-
if (
|
|
1385
|
+
const [first, ...rest] = process.argv.slice(2);
|
|
1386
|
+
if (first === '--version' || first === '-v') { console.log(TOOL_VERSION ?? 'unknown'); process.exit(0); }
|
|
1387
|
+
const cmd = (rest.includes('--help') || rest.includes('-h')) ? 'help' : first; // `promptwheel run --help` is a help request, not a gate run
|
|
1388
|
+
if (cmd in COMMAND_FLAGS) checkFlags(cmd, rest);
|
|
1357
1389
|
if (cmd === 'run') run(rest);
|
|
1358
1390
|
else if (cmd === 'improve') improve(rest);
|
|
1359
1391
|
else if (cmd === 'insights') insights(rest);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
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": {
|