promptwheel 0.7.8 → 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 +4 -3
- package/README.md +2 -2
- package/bin/promptwheel.d.ts +20 -6
- package/bin/promptwheel.mjs +136 -45
- package/package.json +1 -1
package/INTEGRATE.md
CHANGED
|
@@ -19,7 +19,8 @@ importing this package.
|
|
|
19
19
|
|
|
20
20
|
- **Section 1, exit codes.** `0` pass, `1` fail (a guarded metric regressed beyond noise, or
|
|
21
21
|
a mandate was violated), `2` gamed (a win that did not survive a source-only re-run), `3`
|
|
22
|
-
inconclusive (a guard measured nothing,
|
|
22
|
+
inconclusive (a guard measured nothing, or a win whose source-only re-run could not be applied,
|
|
23
|
+
so a pass cannot be certified). An agent reads the
|
|
23
24
|
code as a per-turn reward it cannot inflate; a merge gate reads the same code as a wall it
|
|
24
25
|
cannot self-clear. A usage or config error (a malformed config, a missing `extends` target,
|
|
25
26
|
a bad flag) exits `64` (`EX_USAGE`), which is not a verdict: a CI step must never read a
|
|
@@ -64,7 +65,7 @@ Fixed vocabularies (use exactly these):
|
|
|
64
65
|
`noise` is the measured jitter band, a single number, across the top-level `repeat` runs; a
|
|
65
66
|
delta inside it is `inconclusive`, never a regression. `retained` is the fraction of a
|
|
66
67
|
metric's gain that survived the source-only re-run (Section 3). `confidence`
|
|
67
|
-
(`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
|
|
68
69
|
implementation adds; a minimal conforming report may omit them. `gate` (optional) records a
|
|
69
70
|
truncated SHA-256 fingerprint of the gate definition (metrics, guards, `evaluatorPaths`,
|
|
70
71
|
`gamingThreshold`, `mandates`) at base and head, sets `gate.changed: true` when they differ,
|
|
@@ -133,7 +134,7 @@ Returns the Section 2 verdict object (identical to `run --json`). Reproducible f
|
|
|
133
134
|
| `auto` | boolean | `true` | with no config file and no `config` option, gate the auto-detected test command (zero-config); `false` throws instead |
|
|
134
135
|
| `repeat` | number | `config.repeat ?? 1` | measurement repeats for the noise band |
|
|
135
136
|
| `detectGaming` | boolean | `true` | run the Section 3 source-only re-proof |
|
|
136
|
-
| `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` |
|
|
137
138
|
| `record` | boolean | `false` | append the run to `.promptwheel/outcomes.jsonl` |
|
|
138
139
|
|
|
139
140
|
Preconditions throw (they do not exit the host): not inside a git repository; `auto: false`
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ PromptWheel a1b2c3d → e4f5g6h (×5)
|
|
|
42
42
|
VERDICT: PASS
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Exit `0` pass · `1` fail (a guarded metric regressed, or a mandate was violated) · `2` gamed · `3` inconclusive (a guard measured nothing) · `64` usage or config error (never a verdict). No build step, zero dependencies, Node 18+. The same verdict runs as a PR check in CI, as a per-turn signal inside a coding loop, or in process (`import { check } from 'promptwheel'`); it is the reference check for the [Verified-Outcome Contract](https://github.com/sawbuck-ai/sawbuck/blob/master/CONTRACT.md), and the integration paths are in [INTEGRATE.md](INTEGRATE.md).
|
|
45
|
+
Exit `0` pass · `1` fail (a guarded metric regressed, or a mandate was violated) · `2` gamed · `3` inconclusive (a guard measured nothing, or a win whose source-only re-run could not be applied) · `64` usage or config error (never a verdict). No build step, zero dependencies, Node 18+. The same verdict runs as a PR check in CI, as a per-turn signal inside a coding loop, or in process (`import { check } from 'promptwheel'`); it is the reference check for the [Verified-Outcome Contract](https://github.com/sawbuck-ai/sawbuck/blob/master/CONTRACT.md), and the integration paths are in [INTEGRATE.md](INTEGRATE.md).
|
|
46
46
|
|
|
47
47
|
## Install and use
|
|
48
48
|
|
|
@@ -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;
|
|
@@ -138,14 +138,16 @@ export interface MetricResult extends Evaluation {
|
|
|
138
138
|
name: string;
|
|
139
139
|
direction: Direction;
|
|
140
140
|
guard: boolean;
|
|
141
|
-
/** `true` when the win did not survive the source-only re-run; `null` when the source-only patch
|
|
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
|
+
/** `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;
|
|
143
145
|
/** The metric's value with only the source edits applied. */
|
|
144
146
|
sourceOnly?: number | null;
|
|
145
147
|
/** The fraction of the gain that survived source-only (0..1). */
|
|
146
148
|
retained?: number | null;
|
|
147
149
|
gamingReason?: string;
|
|
148
|
-
/** 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. */
|
|
149
151
|
allowed?: true;
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -232,13 +234,23 @@ export declare const DIRECTIONS: readonly ['up', 'down', 'pass'];
|
|
|
232
234
|
/** Section 1: `pass` 0, `fail` 1, `gamed` 2, `inconclusive` 3 (any other string maps to 1). The CLI exits with this. */
|
|
233
235
|
export declare function verdictExitCode(verdict: Verdict | string): 0 | 1 | 2 | 3;
|
|
234
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The Section 1 verdict from measured metrics, pure. A mandate hit (`mandated`) or a guarded
|
|
239
|
+
* regression is `fail`; a `gamed:true` metric or an unallowed weakened gate is `gamed`;
|
|
240
|
+
* `inconclusive` when an inert guard measured nothing and no metric improved, or when an improved
|
|
241
|
+
* metric is `unprovable` (its source-only re-run could not be applied); else `pass`.
|
|
242
|
+
*/
|
|
243
|
+
export declare function deriveVerdict(metrics: ReadonlyArray<Partial<MetricResult>>, gateInfo?: { weakened?: unknown[]; allowed?: boolean } | null, mandated?: boolean): Verdict;
|
|
244
|
+
|
|
235
245
|
/**
|
|
236
246
|
* Runs the gate in process and returns the Section 2 verdict object (identical to `run --json`).
|
|
237
247
|
* Measures only in throwaway git worktrees, never the caller's working tree. Never prints and
|
|
238
248
|
* never calls `process.exit`: every precondition failure (not a git repository, `auto: false`
|
|
239
249
|
* with no config, a `config` without a non-empty `metrics` array, a malformed or cyclic config,
|
|
240
|
-
* a missing `extends` target, no commits yet
|
|
241
|
-
*
|
|
250
|
+
* a missing `extends` target, no commits yet, a base or head ref that is not in this clone, no
|
|
251
|
+
* commit to compare against) throws an Error whose `name` is `'ConfigError'` and whose `exitCode`
|
|
252
|
+
* is 64, the code the CLI exits with for the same condition. A `repo` that names a subdirectory
|
|
253
|
+
* is resolved to the repository root, where the config lives and the record is written.
|
|
242
254
|
*/
|
|
243
255
|
export declare function check(options?: CheckOptions): Report;
|
|
244
256
|
|
|
@@ -292,8 +304,10 @@ export interface SourceOnlyResult {
|
|
|
292
304
|
}
|
|
293
305
|
|
|
294
306
|
export interface GamingJudgement {
|
|
295
|
-
/** `null` when the source-only patch did not apply. */
|
|
307
|
+
/** `null` when the source-only patch did not apply (then `unprovable` is `true`). */
|
|
296
308
|
gamed: boolean | null;
|
|
309
|
+
/** The win could not be re-proven; gate() makes the verdict `inconclusive`. */
|
|
310
|
+
unprovable?: true;
|
|
297
311
|
sourceOnly: number | null;
|
|
298
312
|
retained: number | null;
|
|
299
313
|
reason: string;
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -31,6 +31,12 @@ const median = (xs) => {
|
|
|
31
31
|
};
|
|
32
32
|
const spread = (xs) => { const a = xs.filter((x) => x != null); return a.length ? Math.max(...a) - Math.min(...a) : 0; };
|
|
33
33
|
|
|
34
|
+
// every temp dir the engine creates (worktrees, source-only worktrees, temp indexes) carries a short
|
|
35
|
+
// hash of the owning repo in its name, so self-heal in one repo never deletes a checkout another
|
|
36
|
+
// repo is still measuring in (a long gate elsewhere must not lose its worktree mid-run)
|
|
37
|
+
function tmpOwnerTag(repo) { let r = repo; try { r = realpathSync(repo); } catch { /* keep as given */ } return createHash('sha1').update(String(r)).digest('hex').slice(0, 8); }
|
|
38
|
+
const tmpPrefix = (repo, kind) => `${kind}-${tmpOwnerTag(repo)}-`;
|
|
39
|
+
|
|
34
40
|
// usage / config errors exit 64 (EX_USAGE), so they can never be confused with a verdict
|
|
35
41
|
// (0 pass, 1 fail, 2 gamed, 3 inconclusive): in CI a config typo must not read as GAMED.
|
|
36
42
|
const EX_USAGE = 64;
|
|
@@ -39,6 +45,19 @@ function die(msg) { console.error(msg); process.exit(EX_USAGE); }
|
|
|
39
45
|
// instead of exiting the host process; the CLI catches it in main() and exits EX_USAGE.
|
|
40
46
|
function bad(msg) { const e = new Error(msg); e.name = 'ConfigError'; e.exitCode = EX_USAGE; throw e; }
|
|
41
47
|
|
|
48
|
+
// the repository root, or a ConfigError: a non-repo cwd must exit 64, never 1 (the FAIL verdict code)
|
|
49
|
+
function repoRoot(cwd) {
|
|
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(); }
|
|
52
|
+
catch { bad(`promptwheel: not inside a git repository (${cwd})`); }
|
|
53
|
+
}
|
|
54
|
+
// a ref the gate is about to check out must be in this clone; the usual miss is a shallow CI
|
|
55
|
+
// checkout (actions/checkout defaults to fetch-depth 1) whose PR base was never fetched
|
|
56
|
+
function ensureCommit(repo, ref, what) {
|
|
57
|
+
try { execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd: repo, stdio: 'ignore' }); }
|
|
58
|
+
catch { bad(`promptwheel: ${what} ref "${ref}" is not in this clone (a shallow checkout? fetch it, e.g. actions/checkout with fetch-depth: 0)`); }
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
function readConfigFile(p) {
|
|
43
62
|
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
44
63
|
catch (e) { bad(`invalid config ${p}: ${e.message}`); }
|
|
@@ -99,7 +118,9 @@ function autoConfig(repo) {
|
|
|
99
118
|
|
|
100
119
|
function resolveBase(repo, base) {
|
|
101
120
|
if (base) return base;
|
|
102
|
-
|
|
121
|
+
let head = '';
|
|
122
|
+
try { head = git(['rev-parse', '--verify', '--quiet', 'HEAD'], repo); }
|
|
123
|
+
catch { bad('promptwheel: no commits yet, make an initial commit before gating'); }
|
|
103
124
|
for (const b of ['origin/main', 'origin/master', 'main', 'master']) {
|
|
104
125
|
let mb = '';
|
|
105
126
|
try { mb = git(['merge-base', 'HEAD', b], repo); } catch { continue; }
|
|
@@ -107,7 +128,8 @@ function resolveBase(repo, base) {
|
|
|
107
128
|
// trivial PASS that verified nothing, so fall through to the last commit instead.
|
|
108
129
|
if (mb && mb !== head) return mb;
|
|
109
130
|
}
|
|
110
|
-
return git(['rev-parse', 'HEAD~1'], repo);
|
|
131
|
+
try { return git(['rev-parse', '--verify', '--quiet', 'HEAD~1'], repo); }
|
|
132
|
+
catch { bad('promptwheel: no commit to compare against (one commit, or a shallow clone): pass --base, or --working'); }
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
function runMetric(cwd, m, env) {
|
|
@@ -167,7 +189,7 @@ function runSetup(wt, cfg, env) {
|
|
|
167
189
|
|
|
168
190
|
// measure every metric `repeat` times at a ref, in a throwaway worktree (never touches your tree)
|
|
169
191
|
function measureAt(repo, ref, cfg, repeat) {
|
|
170
|
-
const wt = mkdtempSync(join(tmpdir(), 'promptwheel
|
|
192
|
+
const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel')));
|
|
171
193
|
git(['worktree', 'add', '--quiet', '--detach', wt, ref], repo);
|
|
172
194
|
try {
|
|
173
195
|
const env = bridgeEnv(repo, wt, cfg);
|
|
@@ -243,7 +265,9 @@ const NON_SOURCE = [
|
|
|
243
265
|
/_spec\.rb$/i,
|
|
244
266
|
/[a-z0-9](Test|Tests)\.(java|kt|kts|scala)$/,
|
|
245
267
|
/\.snap$/i, /(^|\/)golden[^/]*$/i, /\.golden$/i,
|
|
246
|
-
/(
|
|
268
|
+
// a grader/eval/score FILE by name (eval.ts, grader-x.js, score.test.mjs, scoring.ts), never a prefix:
|
|
269
|
+
// evaluate.ts, evaluation.js, scoreboard.js are production source and must stay in the slice
|
|
270
|
+
/(^|\/)(eval|evals|grader|graders|score|scorer|scoring)([._-][^/]*)?\.[cm]?[jt]s$/i,
|
|
247
271
|
// config files only, require a .config/.conf/.setup segment so PRODUCTION source that
|
|
248
272
|
// happens to be named after a tool (e.g. src/installers/eslint.ts) is NOT swept out of
|
|
249
273
|
// the source slice (a false sweep here can flag an honest win as GAMED).
|
|
@@ -332,26 +356,36 @@ function changedSourcePaths(repo, base, head, isEval = () => false) {
|
|
|
332
356
|
return { source: all.filter((p) => !nonSrc(p)), nonSource: all.filter(nonSrc) };
|
|
333
357
|
}
|
|
334
358
|
|
|
335
|
-
// measure
|
|
336
|
-
|
|
359
|
+
// measure the given metric defs in ONE worktree at `base` with ONLY the source slice of base→head
|
|
360
|
+
// applied (checked out once, patched once, set up once), so re-proving three improved metrics costs
|
|
361
|
+
// one checkout, not three. Returns { [name]: { samples, hadSourceChange, applyFailed? } }.
|
|
362
|
+
function measureSourceOnlyAll(repo, base, head, defs, cfg, repeat) {
|
|
363
|
+
const out = {};
|
|
364
|
+
if (!defs.length) return out;
|
|
337
365
|
const { source } = changedSourcePaths(repo, base, head, evaluatorMatcher(cfg));
|
|
338
|
-
if (!source.length)
|
|
339
|
-
const wt = mkdtempSync(join(tmpdir(), 'promptwheel-src
|
|
366
|
+
if (!source.length) { for (const m of defs) out[m.name] = { samples: [], hadSourceChange: false }; return out; } // a "win" with zero source edits = goalposts moved
|
|
367
|
+
const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel-src')));
|
|
340
368
|
git(['worktree', 'add', '--quiet', '--detach', wt, base], repo);
|
|
341
369
|
try {
|
|
342
370
|
const env = bridgeEnv(repo, wt, cfg);
|
|
343
|
-
|
|
371
|
+
// --binary so a binary source file (an image, a model blob under src/) travels in the patch; without
|
|
372
|
+
// it git prints "Binary files differ", the apply fails, and a win the source never earned would
|
|
373
|
+
// read as unprovable. Untrimmed: a binary hunk ends in a blank line the apply needs.
|
|
374
|
+
const patch = execFileSync('git', ['diff', '--binary', base, head, '--', ...source], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
344
375
|
if (patch.trim()) {
|
|
345
376
|
// stdio stderr 'ignore': git apply --3way prints "Applied patch ... cleanly" to stderr; that
|
|
346
377
|
// is noise on an otherwise-quiet run (and corrupts --json for callers that fold in stderr).
|
|
347
|
-
const tryApply = (extra) => { execFileSync('git', ['apply', '--whitespace=nowarn', ...extra], { cwd: wt, input: patch
|
|
378
|
+
const tryApply = (extra) => { execFileSync('git', ['apply', '--whitespace=nowarn', ...extra], { cwd: wt, input: patch, encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] }); };
|
|
348
379
|
try { tryApply(['--3way']); }
|
|
349
|
-
catch { try { tryApply([]); } catch {
|
|
380
|
+
catch { try { tryApply([]); } catch { for (const m of defs) out[m.name] = { samples: [], hadSourceChange: true, applyFailed: true }; return out; } }
|
|
350
381
|
}
|
|
351
382
|
runSetup(wt, cfg, env); // build the patched tree, so a build-gated metric measures the source edit
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
383
|
+
for (const m of defs) {
|
|
384
|
+
const samples = [];
|
|
385
|
+
for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, m, env));
|
|
386
|
+
out[m.name] = { samples, hadSourceChange: true };
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
355
389
|
} finally {
|
|
356
390
|
try { git(['worktree', 'remove', '--force', wt], repo); } catch { rmSync(wt, { recursive: true, force: true }); }
|
|
357
391
|
}
|
|
@@ -371,7 +405,8 @@ function gain(m, before, val) {
|
|
|
371
405
|
// rest came from editing the goalposts). Tune via `gamingThreshold` (config-level or per-metric).
|
|
372
406
|
function judgeGaming(m, srcResult, threshold = 0.5) {
|
|
373
407
|
if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files, only test/config/grader/golden' };
|
|
374
|
-
|
|
408
|
+
// the win could not be re-proven at all: never a pass. gate() turns `unprovable` into INCONCLUSIVE.
|
|
409
|
+
if (srcResult.applyFailed) return { gamed: null, unprovable: true, sourceOnly: null, retained: null, reason: 'the source-only patch could not be applied, so the win cannot be re-proven from the source: inconclusive' };
|
|
375
410
|
const sourceOnly = median(srcResult.samples);
|
|
376
411
|
const full = gain(m, m.before, m.after);
|
|
377
412
|
if (full == null || full <= 0) return { gamed: false, sourceOnly, retained: 1, reason: 'no real improvement to re-prove' };
|
|
@@ -389,7 +424,7 @@ function judgeGaming(m, srcResult, threshold = 0.5) {
|
|
|
389
424
|
// touching the real index or files, so a gate/improve sees a file an agent just added.
|
|
390
425
|
// Returns 'HEAD' when nothing changed.
|
|
391
426
|
function workingSnapshot(repo) {
|
|
392
|
-
const idxDir = mkdtempSync(join(tmpdir(), 'pw-idx
|
|
427
|
+
const idxDir = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'pw-idx')));
|
|
393
428
|
const env = {
|
|
394
429
|
...process.env, GIT_INDEX_FILE: join(idxDir, 'index'),
|
|
395
430
|
GIT_AUTHOR_NAME: 'promptwheel', GIT_AUTHOR_EMAIL: 'promptwheel@local',
|
|
@@ -414,7 +449,9 @@ function workingSnapshot(repo) {
|
|
|
414
449
|
// self-heal: a hard-killed run (SIGKILL / power loss) can leave an orphaned worktree,
|
|
415
450
|
// its `finally` never ran. On the next run, drop stale worktree registry entries and any
|
|
416
451
|
// abandoned /tmp checkout, so nothing accumulates in your repo or temp dir. Never touches a
|
|
417
|
-
// 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).
|
|
418
455
|
function selfHeal(repo) {
|
|
419
456
|
try {
|
|
420
457
|
git(['worktree', 'prune'], repo); // remove registry entries whose dir is already gone (always safe)
|
|
@@ -422,12 +459,16 @@ function selfHeal(repo) {
|
|
|
422
459
|
git(['worktree', 'list', '--porcelain'], repo)
|
|
423
460
|
.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice(9).trim()),
|
|
424
461
|
);
|
|
425
|
-
const tmp = tmpdir(),
|
|
462
|
+
const tmp = tmpdir(), now = Date.now(), mine = tmpOwnerTag(repo);
|
|
463
|
+
const TAGGED = /^(promptwheel-src|promptwheel|pw-idx)-([0-9a-f]{8})-/;
|
|
426
464
|
for (const name of readdirSync(tmp)) {
|
|
427
465
|
if (!name.startsWith('promptwheel-') && !name.startsWith('pw-idx-')) continue; // worktrees + temp-index dirs
|
|
466
|
+
const tag = (name.match(TAGGED) || [])[2] || null;
|
|
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;
|
|
428
469
|
const p = join(tmp, name);
|
|
429
|
-
if (tracked.has(p)) continue;
|
|
430
|
-
try { if (statSync(p).mtimeMs <
|
|
470
|
+
if (tracked.has(p)) continue; // a live/registered worktree, leave it
|
|
471
|
+
try { if (statSync(p).mtimeMs < now - maxAge) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
|
|
431
472
|
}
|
|
432
473
|
} catch { /* cleanup must never block the gate */ }
|
|
433
474
|
}
|
|
@@ -589,12 +630,19 @@ function readAllowTrailer(repo, ref) {
|
|
|
589
630
|
// gamingReason) and returns the report.gate record. An allow trailer can still clear a gamed
|
|
590
631
|
// metric afterwards, in gate().
|
|
591
632
|
function detectGamingPass(repo, base, head, cfg, metrics, repeat) {
|
|
633
|
+
const toProve = [];
|
|
592
634
|
for (const m of metrics) {
|
|
593
635
|
if (m.status !== 'improved') continue;
|
|
594
636
|
const cm = cfg.metrics.find((c) => c.name === m.name);
|
|
595
637
|
if (cm.gamingCheck === false) continue; // tripwire / test-side guards aren't re-proven from source (their gain legitimately lives in test files)
|
|
596
|
-
|
|
638
|
+
toProve.push(cm);
|
|
639
|
+
}
|
|
640
|
+
const srcResults = measureSourceOnlyAll(repo, base, head, toProve, cfg, repeat); // one patched checkout for all of them
|
|
641
|
+
for (const cm of toProve) {
|
|
642
|
+
const m = metrics.find((x) => x.name === cm.name);
|
|
643
|
+
const j = judgeGaming(m, srcResults[cm.name], cm.gamingThreshold ?? cfg.gamingThreshold ?? 0.5);
|
|
597
644
|
m.gamed = j.gamed; m.sourceOnly = j.sourceOnly; m.retained = j.retained; m.gamingReason = j.reason;
|
|
645
|
+
if (j.unprovable) m.unprovable = true;
|
|
598
646
|
}
|
|
599
647
|
const evalChanged = changedEvaluatorPaths(repo, base, head, cfg);
|
|
600
648
|
if (evalChanged.length) {
|
|
@@ -632,6 +680,23 @@ function detectGamingPass(repo, base, head, cfg, metrics, repeat) {
|
|
|
632
680
|
return info;
|
|
633
681
|
}
|
|
634
682
|
|
|
683
|
+
// Section 1 verdict from the measured facts, pure, so the precedence can be checked in isolation:
|
|
684
|
+
// a mandate hit or a guarded regression is FAIL; a win that did not survive the source-only re-run
|
|
685
|
+
// (or a weakened gate) is GAMED; INCONCLUSIVE when nothing could be certified. A guard that never
|
|
686
|
+
// runs (inert: a pass/fail metric stuck at 0 across both refs) verifies nothing, so a bare no-op
|
|
687
|
+
// run is not laundered into a green PASS, BUT an inert guard must not bury a genuine, measured win
|
|
688
|
+
// elsewhere (that would make `improve` revert good changes and misread them as a plateau). And a
|
|
689
|
+
// win whose source-only re-run could not be applied (`unprovable`) is never a PASS either: a green
|
|
690
|
+
// the engine could not re-prove is exactly the green it exists to doubt.
|
|
691
|
+
export function deriveVerdict(metrics, gateInfo = null, mandated = false) {
|
|
692
|
+
const failed = metrics.some((m) => m.guard && !m.ok);
|
|
693
|
+
const gamed = metrics.some((m) => m.gamed === true) || !!(gateInfo && gateInfo.weakened && !gateInfo.allowed);
|
|
694
|
+
const inertNoWin = metrics.some((m) => m.inert) && !metrics.some((m) => m.status === 'improved');
|
|
695
|
+
const unprovable = metrics.some((m) => m.unprovable === true);
|
|
696
|
+
const inconclusive = !failed && !gamed && (inertNoWin || unprovable);
|
|
697
|
+
return (mandated || failed) ? 'fail' : gamed ? 'gamed' : inconclusive ? 'inconclusive' : 'pass';
|
|
698
|
+
}
|
|
699
|
+
|
|
635
700
|
function gate(repo, opts) {
|
|
636
701
|
selfHeal(repo);
|
|
637
702
|
const cfg = opts.config ?? loadConfig(repo);
|
|
@@ -649,6 +714,8 @@ function gate(repo, opts) {
|
|
|
649
714
|
} else {
|
|
650
715
|
base = resolveBase(repo, opts.base);
|
|
651
716
|
head = opts.head || 'HEAD';
|
|
717
|
+
ensureCommit(repo, base, 'base');
|
|
718
|
+
ensureCommit(repo, head, 'head');
|
|
652
719
|
}
|
|
653
720
|
|
|
654
721
|
const before = measureAt(repo, base, cfg, repeat);
|
|
@@ -661,14 +728,20 @@ function gate(repo, opts) {
|
|
|
661
728
|
const gateInfo = opts.detectGaming ? detectGamingPass(repo, base, head, cfg, metrics, repeat) : null;
|
|
662
729
|
// declared override: a Promptwheel-Allow trailer (or --allow) acknowledges an intentional
|
|
663
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.
|
|
664
734
|
const allow = opts.allow ? { reason: String(opts.allow).slice(0, 300) } : (opts.working ? null : readAllowTrailer(repo, head));
|
|
665
735
|
let allowed = null;
|
|
666
736
|
if (opts.detectGaming && allow) {
|
|
667
737
|
const gm = metrics.filter((m) => m.gamed === true);
|
|
738
|
+
const up = metrics.filter((m) => m.unprovable === true);
|
|
668
739
|
const gw = !!(gateInfo && gateInfo.weakened);
|
|
669
|
-
if (gm.length || gw) {
|
|
670
|
-
|
|
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'] : [])] };
|
|
671
743
|
for (const m of gm) { m.gamed = false; m.allowed = true; }
|
|
744
|
+
for (const m of up) { m.unprovable = false; m.allowed = true; }
|
|
672
745
|
if (gw) gateInfo.allowed = true;
|
|
673
746
|
}
|
|
674
747
|
}
|
|
@@ -678,14 +751,7 @@ function gate(repo, opts) {
|
|
|
678
751
|
const baseMandates = (gateInfo && gateInfo.baseMandates) || (!opts.detectGaming ? ((gateDefAtRef(repo, base) || {}).scalars || {}).mandates : null);
|
|
679
752
|
const mandateViolations = checkMandates(repo, base, head, cfg, baseMandates);
|
|
680
753
|
const mandated = mandateViolations.length > 0;
|
|
681
|
-
const
|
|
682
|
-
const gamed = metrics.some((m) => m.gamed === true) || !!(gateInfo && gateInfo.weakened && !gateInfo.allowed);
|
|
683
|
-
// a guard that never runs (inert: a pass/fail metric stuck at 0 across both refs) verifies
|
|
684
|
-
// nothing, don't launder that into a green PASS. The honest verdict is "couldn't measure".
|
|
685
|
-
// BUT scope it to a bare no-op run: an inert guard must NOT bury a genuine, measured win
|
|
686
|
-
// elsewhere (that would make `improve` revert good changes and misread them as a plateau).
|
|
687
|
-
const inconclusive = !failed && !gamed && metrics.some((m) => m.inert) && !metrics.some((m) => m.status === 'improved');
|
|
688
|
-
const verdict = (mandated || failed) ? 'fail' : gamed ? 'gamed' : inconclusive ? 'inconclusive' : 'pass';
|
|
754
|
+
const verdict = deriveVerdict(metrics, gateInfo, mandated);
|
|
689
755
|
const report = {
|
|
690
756
|
base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs',
|
|
691
757
|
verdict, metrics,
|
|
@@ -707,7 +773,7 @@ function gate(repo, opts) {
|
|
|
707
773
|
|
|
708
774
|
function run(argv) {
|
|
709
775
|
const args = parseArgs(argv);
|
|
710
|
-
const repo =
|
|
776
|
+
const repo = repoRoot(process.cwd());
|
|
711
777
|
let config;
|
|
712
778
|
if (!hasConfigFile(repo) && !args.noAuto) {
|
|
713
779
|
config = autoConfig(repo);
|
|
@@ -733,7 +799,7 @@ function recordOutcome(repo, report) {
|
|
|
733
799
|
function improve(argv) {
|
|
734
800
|
const args = parseArgs(argv);
|
|
735
801
|
if (!args.attempt) die('improve requires --attempt "<command that changes the repo>"');
|
|
736
|
-
const repo =
|
|
802
|
+
const repo = repoRoot(process.cwd());
|
|
737
803
|
const dirty = git(['status', '--porcelain'], repo).split('\n').filter((l) => l.trim() && !l.includes('.promptwheel'));
|
|
738
804
|
if (dirty.length) die('working tree not clean, commit or stash first (improve needs a clean base to revert to)');
|
|
739
805
|
|
|
@@ -781,7 +847,7 @@ function revert(repo) {
|
|
|
781
847
|
// The reader is tolerant of older records that carry extra fields (cohort, label, subsystems).
|
|
782
848
|
function insights(argv) {
|
|
783
849
|
const args = parseArgs(argv);
|
|
784
|
-
const repo =
|
|
850
|
+
const repo = repoRoot(process.cwd());
|
|
785
851
|
const f = join(repo, '.promptwheel', 'outcomes.jsonl');
|
|
786
852
|
if (!existsSync(f)) die('no outcome record yet, run the gate a few times first (.promptwheel/outcomes.jsonl)');
|
|
787
853
|
const runs = readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
@@ -914,7 +980,7 @@ function init(argv) {
|
|
|
914
980
|
for (const [k, p] of Object.entries(PRESETS)) console.log(` ${k.padEnd(13)} ${p.desc}`);
|
|
915
981
|
return;
|
|
916
982
|
}
|
|
917
|
-
const repo = (() => { try { return
|
|
983
|
+
const repo = (() => { try { return repoRoot(process.cwd()); } catch { return process.cwd(); } })();
|
|
918
984
|
const out = join(repo, 'promptwheel.config.json');
|
|
919
985
|
if (existsSync(out) && !argv.includes('--force')) die('promptwheel.config.json already exists, pass --force to overwrite');
|
|
920
986
|
const pi = argv.indexOf('--preset');
|
|
@@ -993,7 +1059,7 @@ function writeAgentBlock(repo) {
|
|
|
993
1059
|
// observability: list the EFFECTIVE guardrails (incl. inherited) + each one's flag record
|
|
994
1060
|
function guards(argv) {
|
|
995
1061
|
const args = parseArgs(argv);
|
|
996
|
-
const repo =
|
|
1062
|
+
const repo = repoRoot(process.cwd());
|
|
997
1063
|
const cfg = loadConfig(repo);
|
|
998
1064
|
const hist = {};
|
|
999
1065
|
const f = join(repo, '.promptwheel', 'outcomes.jsonl');
|
|
@@ -1136,12 +1202,11 @@ export function verdictExitCode(verdict) {
|
|
|
1136
1202
|
// allow string a declared override reason (same as --allow / a Promptwheel-Allow: trailer)
|
|
1137
1203
|
// record boolean append the run to .promptwheel/outcomes.jsonl (default: false)
|
|
1138
1204
|
export function check(options = {}) {
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
} catch { bad(`promptwheel check: not inside a git repository (${options.repo || process.cwd()})`); }
|
|
1205
|
+
// `repo` may name a subdirectory: the gate runs at the toplevel (that is where the config lives
|
|
1206
|
+
// and where .promptwheel/ is written), exactly as the CLI does from the same directory
|
|
1207
|
+
let start = process.cwd();
|
|
1208
|
+
if (options.repo) { try { start = realpathSync(options.repo); } catch { bad(`promptwheel: not inside a git repository (${options.repo})`); } }
|
|
1209
|
+
const repo = repoRoot(start);
|
|
1145
1210
|
|
|
1146
1211
|
let config = options.config;
|
|
1147
1212
|
if (config !== undefined) {
|
|
@@ -1200,7 +1265,9 @@ export function conformsToContract(report) {
|
|
|
1200
1265
|
if (m.delta !== undefined && !numOrNull(m.delta)) E(`${at}.delta must be a number or null`);
|
|
1201
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)`);
|
|
1202
1267
|
if (m.confidence !== undefined && typeof m.confidence !== 'string') E(`${at}.confidence must be a string`);
|
|
1203
|
-
if (m.gamed !== undefined && typeof m.gamed !== 'boolean') E(`${at}.gamed must be a boolean`);
|
|
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`);
|
|
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)`);
|
|
1204
1271
|
if (m.allowed !== undefined && typeof m.allowed !== 'boolean') E(`${at}.allowed must be a boolean`);
|
|
1205
1272
|
if (m.sourceOnly !== undefined && !numOrNull(m.sourceOnly)) E(`${at}.sourceOnly must be a number or null`);
|
|
1206
1273
|
if (m.retained !== undefined && !numOrNull(m.retained)) E(`${at}.retained must be a number or null`);
|
|
@@ -1237,6 +1304,8 @@ export function conformsToContract(report) {
|
|
|
1237
1304
|
E("verdict 'pass' but a guarded metric regressed (guard:true, ok:false)");
|
|
1238
1305
|
if (report.verdict === 'pass' && report.metrics.some((m) => m.gamed === true))
|
|
1239
1306
|
E("verdict 'pass' but a metric is gamed:true");
|
|
1307
|
+
if (report.verdict === 'pass' && report.metrics.some((m) => m.unprovable === true))
|
|
1308
|
+
E("verdict 'pass' but a metric is unprovable (its source-only re-run could not be applied)");
|
|
1240
1309
|
if (report.verdict === 'pass' && gateWeakened)
|
|
1241
1310
|
E("verdict 'pass' but report.gate.weakened is non-empty (the gate was weakened)");
|
|
1242
1311
|
if (report.verdict === 'gamed' && !report.metrics.some((m) => m.gamed === true) && !gateWeakened)
|
|
@@ -1292,9 +1361,31 @@ function conformance(argv) {
|
|
|
1292
1361
|
process.exit(1);
|
|
1293
1362
|
}
|
|
1294
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
|
+
|
|
1295
1384
|
function dispatch() {
|
|
1296
|
-
const [
|
|
1297
|
-
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);
|
|
1298
1389
|
if (cmd === 'run') run(rest);
|
|
1299
1390
|
else if (cmd === 'improve') improve(rest);
|
|
1300
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": {
|