promptwheel 0.7.7 → 0.7.9
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 -1
- package/README.md +1 -1
- package/bin/promptwheel.d.ts +20 -10
- package/bin/promptwheel.mjs +104 -43
- 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
|
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
|
|
package/bin/promptwheel.d.ts
CHANGED
|
@@ -138,8 +138,10 @@ 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
|
+
/** The source-only patch could not be applied, so this win could not be re-proven: the verdict is `inconclusive`. */
|
|
144
|
+
unprovable?: true;
|
|
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). */
|
|
@@ -233,16 +235,22 @@ export declare const DIRECTIONS: readonly ['up', 'down', 'pass'];
|
|
|
233
235
|
export declare function verdictExitCode(verdict: Verdict | string): 0 | 1 | 2 | 3;
|
|
234
236
|
|
|
235
237
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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`.
|
|
240
242
|
*/
|
|
243
|
+
export declare function deriveVerdict(metrics: ReadonlyArray<Partial<MetricResult>>, gateInfo?: { weakened?: unknown[]; allowed?: boolean } | null, mandated?: boolean): Verdict;
|
|
244
|
+
|
|
241
245
|
/**
|
|
242
|
-
* Runs the gate in process and returns the
|
|
243
|
-
*
|
|
244
|
-
* `
|
|
245
|
-
*
|
|
246
|
+
* Runs the gate in process and returns the Section 2 verdict object (identical to `run --json`).
|
|
247
|
+
* Measures only in throwaway git worktrees, never the caller's working tree. Never prints and
|
|
248
|
+
* never calls `process.exit`: every precondition failure (not a git repository, `auto: false`
|
|
249
|
+
* with no config, a `config` without a non-empty `metrics` array, a malformed or cyclic config,
|
|
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.
|
|
246
254
|
*/
|
|
247
255
|
export declare function check(options?: CheckOptions): Report;
|
|
248
256
|
|
|
@@ -296,8 +304,10 @@ export interface SourceOnlyResult {
|
|
|
296
304
|
}
|
|
297
305
|
|
|
298
306
|
export interface GamingJudgement {
|
|
299
|
-
/** `null` when the source-only patch did not apply. */
|
|
307
|
+
/** `null` when the source-only patch did not apply (then `unprovable` is `true`). */
|
|
300
308
|
gamed: boolean | null;
|
|
309
|
+
/** The win could not be re-proven; gate() makes the verdict `inconclusive`. */
|
|
310
|
+
unprovable?: true;
|
|
301
311
|
sourceOnly: number | null;
|
|
302
312
|
retained: number | null;
|
|
303
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,18 @@ 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
|
+
try { return git(['rev-parse', '--show-toplevel'], cwd); }
|
|
51
|
+
catch { bad(`promptwheel: not inside a git repository (${cwd})`); }
|
|
52
|
+
}
|
|
53
|
+
// a ref the gate is about to check out must be in this clone; the usual miss is a shallow CI
|
|
54
|
+
// checkout (actions/checkout defaults to fetch-depth 1) whose PR base was never fetched
|
|
55
|
+
function ensureCommit(repo, ref, what) {
|
|
56
|
+
try { execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd: repo, stdio: 'ignore' }); }
|
|
57
|
+
catch { bad(`promptwheel: ${what} ref "${ref}" is not in this clone (a shallow checkout? fetch it, e.g. actions/checkout with fetch-depth: 0)`); }
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function readConfigFile(p) {
|
|
43
61
|
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
44
62
|
catch (e) { bad(`invalid config ${p}: ${e.message}`); }
|
|
@@ -99,7 +117,9 @@ function autoConfig(repo) {
|
|
|
99
117
|
|
|
100
118
|
function resolveBase(repo, base) {
|
|
101
119
|
if (base) return base;
|
|
102
|
-
|
|
120
|
+
let head = '';
|
|
121
|
+
try { head = git(['rev-parse', '--verify', '--quiet', 'HEAD'], repo); }
|
|
122
|
+
catch { bad('promptwheel: no commits yet, make an initial commit before gating'); }
|
|
103
123
|
for (const b of ['origin/main', 'origin/master', 'main', 'master']) {
|
|
104
124
|
let mb = '';
|
|
105
125
|
try { mb = git(['merge-base', 'HEAD', b], repo); } catch { continue; }
|
|
@@ -107,7 +127,8 @@ function resolveBase(repo, base) {
|
|
|
107
127
|
// trivial PASS that verified nothing, so fall through to the last commit instead.
|
|
108
128
|
if (mb && mb !== head) return mb;
|
|
109
129
|
}
|
|
110
|
-
return git(['rev-parse', 'HEAD~1'], repo);
|
|
130
|
+
try { return git(['rev-parse', '--verify', '--quiet', 'HEAD~1'], repo); }
|
|
131
|
+
catch { bad('promptwheel: no commit to compare against (one commit, or a shallow clone): pass --base, or --working'); }
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
function runMetric(cwd, m, env) {
|
|
@@ -167,7 +188,7 @@ function runSetup(wt, cfg, env) {
|
|
|
167
188
|
|
|
168
189
|
// measure every metric `repeat` times at a ref, in a throwaway worktree (never touches your tree)
|
|
169
190
|
function measureAt(repo, ref, cfg, repeat) {
|
|
170
|
-
const wt = mkdtempSync(join(tmpdir(), 'promptwheel
|
|
191
|
+
const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel')));
|
|
171
192
|
git(['worktree', 'add', '--quiet', '--detach', wt, ref], repo);
|
|
172
193
|
try {
|
|
173
194
|
const env = bridgeEnv(repo, wt, cfg);
|
|
@@ -243,7 +264,9 @@ const NON_SOURCE = [
|
|
|
243
264
|
/_spec\.rb$/i,
|
|
244
265
|
/[a-z0-9](Test|Tests)\.(java|kt|kts|scala)$/,
|
|
245
266
|
/\.snap$/i, /(^|\/)golden[^/]*$/i, /\.golden$/i,
|
|
246
|
-
/(
|
|
267
|
+
// a grader/eval/score FILE by name (eval.ts, grader-x.js, score.test.mjs, scoring.ts), never a prefix:
|
|
268
|
+
// evaluate.ts, evaluation.js, scoreboard.js are production source and must stay in the slice
|
|
269
|
+
/(^|\/)(eval|evals|grader|graders|score|scorer|scoring)([._-][^/]*)?\.[cm]?[jt]s$/i,
|
|
247
270
|
// config files only, require a .config/.conf/.setup segment so PRODUCTION source that
|
|
248
271
|
// happens to be named after a tool (e.g. src/installers/eslint.ts) is NOT swept out of
|
|
249
272
|
// the source slice (a false sweep here can flag an honest win as GAMED).
|
|
@@ -332,26 +355,36 @@ function changedSourcePaths(repo, base, head, isEval = () => false) {
|
|
|
332
355
|
return { source: all.filter((p) => !nonSrc(p)), nonSource: all.filter(nonSrc) };
|
|
333
356
|
}
|
|
334
357
|
|
|
335
|
-
// measure
|
|
336
|
-
|
|
358
|
+
// measure the given metric defs in ONE worktree at `base` with ONLY the source slice of base→head
|
|
359
|
+
// applied (checked out once, patched once, set up once), so re-proving three improved metrics costs
|
|
360
|
+
// one checkout, not three. Returns { [name]: { samples, hadSourceChange, applyFailed? } }.
|
|
361
|
+
function measureSourceOnlyAll(repo, base, head, defs, cfg, repeat) {
|
|
362
|
+
const out = {};
|
|
363
|
+
if (!defs.length) return out;
|
|
337
364
|
const { source } = changedSourcePaths(repo, base, head, evaluatorMatcher(cfg));
|
|
338
|
-
if (!source.length)
|
|
339
|
-
const wt = mkdtempSync(join(tmpdir(), 'promptwheel-src
|
|
365
|
+
if (!source.length) { for (const m of defs) out[m.name] = { samples: [], hadSourceChange: false }; return out; } // a "win" with zero source edits = goalposts moved
|
|
366
|
+
const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel-src')));
|
|
340
367
|
git(['worktree', 'add', '--quiet', '--detach', wt, base], repo);
|
|
341
368
|
try {
|
|
342
369
|
const env = bridgeEnv(repo, wt, cfg);
|
|
343
|
-
|
|
370
|
+
// --binary so a binary source file (an image, a model blob under src/) travels in the patch; without
|
|
371
|
+
// it git prints "Binary files differ", the apply fails, and a win the source never earned would
|
|
372
|
+
// read as unprovable. Untrimmed: a binary hunk ends in a blank line the apply needs.
|
|
373
|
+
const patch = execFileSync('git', ['diff', '--binary', base, head, '--', ...source], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
344
374
|
if (patch.trim()) {
|
|
345
375
|
// stdio stderr 'ignore': git apply --3way prints "Applied patch ... cleanly" to stderr; that
|
|
346
376
|
// 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
|
|
377
|
+
const tryApply = (extra) => { execFileSync('git', ['apply', '--whitespace=nowarn', ...extra], { cwd: wt, input: patch, encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] }); };
|
|
348
378
|
try { tryApply(['--3way']); }
|
|
349
|
-
catch { try { tryApply([]); } catch {
|
|
379
|
+
catch { try { tryApply([]); } catch { for (const m of defs) out[m.name] = { samples: [], hadSourceChange: true, applyFailed: true }; return out; } }
|
|
350
380
|
}
|
|
351
381
|
runSetup(wt, cfg, env); // build the patched tree, so a build-gated metric measures the source edit
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
382
|
+
for (const m of defs) {
|
|
383
|
+
const samples = [];
|
|
384
|
+
for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, m, env));
|
|
385
|
+
out[m.name] = { samples, hadSourceChange: true };
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
355
388
|
} finally {
|
|
356
389
|
try { git(['worktree', 'remove', '--force', wt], repo); } catch { rmSync(wt, { recursive: true, force: true }); }
|
|
357
390
|
}
|
|
@@ -371,7 +404,8 @@ function gain(m, before, val) {
|
|
|
371
404
|
// rest came from editing the goalposts). Tune via `gamingThreshold` (config-level or per-metric).
|
|
372
405
|
function judgeGaming(m, srcResult, threshold = 0.5) {
|
|
373
406
|
if (!srcResult.hadSourceChange) return { gamed: true, sourceOnly: null, retained: 0, reason: 'the "win" changed zero production-source files, only test/config/grader/golden' };
|
|
374
|
-
|
|
407
|
+
// the win could not be re-proven at all: never a pass. gate() turns `unprovable` into INCONCLUSIVE.
|
|
408
|
+
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
409
|
const sourceOnly = median(srcResult.samples);
|
|
376
410
|
const full = gain(m, m.before, m.after);
|
|
377
411
|
if (full == null || full <= 0) return { gamed: false, sourceOnly, retained: 1, reason: 'no real improvement to re-prove' };
|
|
@@ -389,7 +423,7 @@ function judgeGaming(m, srcResult, threshold = 0.5) {
|
|
|
389
423
|
// touching the real index or files, so a gate/improve sees a file an agent just added.
|
|
390
424
|
// Returns 'HEAD' when nothing changed.
|
|
391
425
|
function workingSnapshot(repo) {
|
|
392
|
-
const idxDir = mkdtempSync(join(tmpdir(), 'pw-idx
|
|
426
|
+
const idxDir = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'pw-idx')));
|
|
393
427
|
const env = {
|
|
394
428
|
...process.env, GIT_INDEX_FILE: join(idxDir, 'index'),
|
|
395
429
|
GIT_AUTHOR_NAME: 'promptwheel', GIT_AUTHOR_EMAIL: 'promptwheel@local',
|
|
@@ -422,12 +456,16 @@ function selfHeal(repo) {
|
|
|
422
456
|
git(['worktree', 'list', '--porcelain'], repo)
|
|
423
457
|
.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice(9).trim()),
|
|
424
458
|
);
|
|
425
|
-
const tmp = tmpdir(),
|
|
459
|
+
const tmp = tmpdir(), now = Date.now(), mine = tmpOwnerTag(repo);
|
|
460
|
+
const TAGGED = /^(promptwheel-src|promptwheel|pw-idx)-([0-9a-f]{8})-/;
|
|
426
461
|
for (const name of readdirSync(tmp)) {
|
|
427
462
|
if (!name.startsWith('promptwheel-') && !name.startsWith('pw-idx-')) continue; // worktrees + temp-index dirs
|
|
463
|
+
const tag = (name.match(TAGGED) || [])[2] || null;
|
|
464
|
+
if (tag && tag !== mine) continue; // another repo's checkout, maybe mid-measurement: never ours to remove
|
|
465
|
+
const maxAge = tag ? 3600_000 : 86_400_000; // an untagged dir (an older engine's) only after a full day
|
|
428
466
|
const p = join(tmp, name);
|
|
429
|
-
if (tracked.has(p)) continue;
|
|
430
|
-
try { if (statSync(p).mtimeMs <
|
|
467
|
+
if (tracked.has(p)) continue; // a live/registered worktree, leave it
|
|
468
|
+
try { if (statSync(p).mtimeMs < now - maxAge) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
|
|
431
469
|
}
|
|
432
470
|
} catch { /* cleanup must never block the gate */ }
|
|
433
471
|
}
|
|
@@ -589,12 +627,19 @@ function readAllowTrailer(repo, ref) {
|
|
|
589
627
|
// gamingReason) and returns the report.gate record. An allow trailer can still clear a gamed
|
|
590
628
|
// metric afterwards, in gate().
|
|
591
629
|
function detectGamingPass(repo, base, head, cfg, metrics, repeat) {
|
|
630
|
+
const toProve = [];
|
|
592
631
|
for (const m of metrics) {
|
|
593
632
|
if (m.status !== 'improved') continue;
|
|
594
633
|
const cm = cfg.metrics.find((c) => c.name === m.name);
|
|
595
634
|
if (cm.gamingCheck === false) continue; // tripwire / test-side guards aren't re-proven from source (their gain legitimately lives in test files)
|
|
596
|
-
|
|
635
|
+
toProve.push(cm);
|
|
636
|
+
}
|
|
637
|
+
const srcResults = measureSourceOnlyAll(repo, base, head, toProve, cfg, repeat); // one patched checkout for all of them
|
|
638
|
+
for (const cm of toProve) {
|
|
639
|
+
const m = metrics.find((x) => x.name === cm.name);
|
|
640
|
+
const j = judgeGaming(m, srcResults[cm.name], cm.gamingThreshold ?? cfg.gamingThreshold ?? 0.5);
|
|
597
641
|
m.gamed = j.gamed; m.sourceOnly = j.sourceOnly; m.retained = j.retained; m.gamingReason = j.reason;
|
|
642
|
+
if (j.unprovable) m.unprovable = true;
|
|
598
643
|
}
|
|
599
644
|
const evalChanged = changedEvaluatorPaths(repo, base, head, cfg);
|
|
600
645
|
if (evalChanged.length) {
|
|
@@ -632,6 +677,23 @@ function detectGamingPass(repo, base, head, cfg, metrics, repeat) {
|
|
|
632
677
|
return info;
|
|
633
678
|
}
|
|
634
679
|
|
|
680
|
+
// Section 1 verdict from the measured facts, pure, so the precedence can be checked in isolation:
|
|
681
|
+
// a mandate hit or a guarded regression is FAIL; a win that did not survive the source-only re-run
|
|
682
|
+
// (or a weakened gate) is GAMED; INCONCLUSIVE when nothing could be certified. A guard that never
|
|
683
|
+
// runs (inert: a pass/fail metric stuck at 0 across both refs) verifies nothing, so a bare no-op
|
|
684
|
+
// run is not laundered into a green PASS, BUT an inert guard must not bury a genuine, measured win
|
|
685
|
+
// elsewhere (that would make `improve` revert good changes and misread them as a plateau). And a
|
|
686
|
+
// win whose source-only re-run could not be applied (`unprovable`) is never a PASS either: a green
|
|
687
|
+
// the engine could not re-prove is exactly the green it exists to doubt.
|
|
688
|
+
export function deriveVerdict(metrics, gateInfo = null, mandated = false) {
|
|
689
|
+
const failed = metrics.some((m) => m.guard && !m.ok);
|
|
690
|
+
const gamed = metrics.some((m) => m.gamed === true) || !!(gateInfo && gateInfo.weakened && !gateInfo.allowed);
|
|
691
|
+
const inertNoWin = metrics.some((m) => m.inert) && !metrics.some((m) => m.status === 'improved');
|
|
692
|
+
const unprovable = metrics.some((m) => m.unprovable === true);
|
|
693
|
+
const inconclusive = !failed && !gamed && (inertNoWin || unprovable);
|
|
694
|
+
return (mandated || failed) ? 'fail' : gamed ? 'gamed' : inconclusive ? 'inconclusive' : 'pass';
|
|
695
|
+
}
|
|
696
|
+
|
|
635
697
|
function gate(repo, opts) {
|
|
636
698
|
selfHeal(repo);
|
|
637
699
|
const cfg = opts.config ?? loadConfig(repo);
|
|
@@ -649,6 +711,8 @@ function gate(repo, opts) {
|
|
|
649
711
|
} else {
|
|
650
712
|
base = resolveBase(repo, opts.base);
|
|
651
713
|
head = opts.head || 'HEAD';
|
|
714
|
+
ensureCommit(repo, base, 'base');
|
|
715
|
+
ensureCommit(repo, head, 'head');
|
|
652
716
|
}
|
|
653
717
|
|
|
654
718
|
const before = measureAt(repo, base, cfg, repeat);
|
|
@@ -678,14 +742,7 @@ function gate(repo, opts) {
|
|
|
678
742
|
const baseMandates = (gateInfo && gateInfo.baseMandates) || (!opts.detectGaming ? ((gateDefAtRef(repo, base) || {}).scalars || {}).mandates : null);
|
|
679
743
|
const mandateViolations = checkMandates(repo, base, head, cfg, baseMandates);
|
|
680
744
|
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';
|
|
745
|
+
const verdict = deriveVerdict(metrics, gateInfo, mandated);
|
|
689
746
|
const report = {
|
|
690
747
|
base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs',
|
|
691
748
|
verdict, metrics,
|
|
@@ -707,7 +764,7 @@ function gate(repo, opts) {
|
|
|
707
764
|
|
|
708
765
|
function run(argv) {
|
|
709
766
|
const args = parseArgs(argv);
|
|
710
|
-
const repo =
|
|
767
|
+
const repo = repoRoot(process.cwd());
|
|
711
768
|
let config;
|
|
712
769
|
if (!hasConfigFile(repo) && !args.noAuto) {
|
|
713
770
|
config = autoConfig(repo);
|
|
@@ -733,7 +790,7 @@ function recordOutcome(repo, report) {
|
|
|
733
790
|
function improve(argv) {
|
|
734
791
|
const args = parseArgs(argv);
|
|
735
792
|
if (!args.attempt) die('improve requires --attempt "<command that changes the repo>"');
|
|
736
|
-
const repo =
|
|
793
|
+
const repo = repoRoot(process.cwd());
|
|
737
794
|
const dirty = git(['status', '--porcelain'], repo).split('\n').filter((l) => l.trim() && !l.includes('.promptwheel'));
|
|
738
795
|
if (dirty.length) die('working tree not clean, commit or stash first (improve needs a clean base to revert to)');
|
|
739
796
|
|
|
@@ -781,7 +838,7 @@ function revert(repo) {
|
|
|
781
838
|
// The reader is tolerant of older records that carry extra fields (cohort, label, subsystems).
|
|
782
839
|
function insights(argv) {
|
|
783
840
|
const args = parseArgs(argv);
|
|
784
|
-
const repo =
|
|
841
|
+
const repo = repoRoot(process.cwd());
|
|
785
842
|
const f = join(repo, '.promptwheel', 'outcomes.jsonl');
|
|
786
843
|
if (!existsSync(f)) die('no outcome record yet, run the gate a few times first (.promptwheel/outcomes.jsonl)');
|
|
787
844
|
const runs = readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
@@ -914,7 +971,7 @@ function init(argv) {
|
|
|
914
971
|
for (const [k, p] of Object.entries(PRESETS)) console.log(` ${k.padEnd(13)} ${p.desc}`);
|
|
915
972
|
return;
|
|
916
973
|
}
|
|
917
|
-
const repo = (() => { try { return
|
|
974
|
+
const repo = (() => { try { return repoRoot(process.cwd()); } catch { return process.cwd(); } })();
|
|
918
975
|
const out = join(repo, 'promptwheel.config.json');
|
|
919
976
|
if (existsSync(out) && !argv.includes('--force')) die('promptwheel.config.json already exists, pass --force to overwrite');
|
|
920
977
|
const pi = argv.indexOf('--preset');
|
|
@@ -993,7 +1050,7 @@ function writeAgentBlock(repo) {
|
|
|
993
1050
|
// observability: list the EFFECTIVE guardrails (incl. inherited) + each one's flag record
|
|
994
1051
|
function guards(argv) {
|
|
995
1052
|
const args = parseArgs(argv);
|
|
996
|
-
const repo =
|
|
1053
|
+
const repo = repoRoot(process.cwd());
|
|
997
1054
|
const cfg = loadConfig(repo);
|
|
998
1055
|
const hist = {};
|
|
999
1056
|
const f = join(repo, '.promptwheel', 'outcomes.jsonl');
|
|
@@ -1120,8 +1177,9 @@ export function verdictExitCode(verdict) {
|
|
|
1120
1177
|
// Run the gate in process and RETURN the Section 2 verdict object (identical to what
|
|
1121
1178
|
// `run --json` prints). Measurement happens only in throwaway worktrees, never the
|
|
1122
1179
|
// caller's working tree (constitution #4). This function never prints and never calls
|
|
1123
|
-
// process.exit;
|
|
1124
|
-
//
|
|
1180
|
+
// process.exit; every precondition failure (not a git repo, a missing / invalid config, no
|
|
1181
|
+
// commits) throws an Error named ConfigError with exitCode 64. Reproducible from (base, head,
|
|
1182
|
+
// config) alone.
|
|
1125
1183
|
//
|
|
1126
1184
|
// repo string repo path (default: discovered from process.cwd())
|
|
1127
1185
|
// base string base ref (default: merge-base vs origin/main..., else HEAD~1)
|
|
@@ -1135,18 +1193,18 @@ export function verdictExitCode(verdict) {
|
|
|
1135
1193
|
// allow string a declared override reason (same as --allow / a Promptwheel-Allow: trailer)
|
|
1136
1194
|
// record boolean append the run to .promptwheel/outcomes.jsonl (default: false)
|
|
1137
1195
|
export function check(options = {}) {
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1196
|
+
// `repo` may name a subdirectory: the gate runs at the toplevel (that is where the config lives
|
|
1197
|
+
// and where .promptwheel/ is written), exactly as the CLI does from the same directory
|
|
1198
|
+
let start = process.cwd();
|
|
1199
|
+
if (options.repo) { try { start = realpathSync(options.repo); } catch { bad(`promptwheel: not inside a git repository (${options.repo})`); } }
|
|
1200
|
+
const repo = repoRoot(start);
|
|
1143
1201
|
|
|
1144
1202
|
let config = options.config;
|
|
1145
1203
|
if (config !== undefined) {
|
|
1146
1204
|
if (!config || typeof config !== 'object' || !Array.isArray(config.metrics) || config.metrics.length === 0)
|
|
1147
|
-
|
|
1205
|
+
bad('promptwheel check: options.config.metrics must be a non-empty array');
|
|
1148
1206
|
} else if (!hasConfigFile(repo)) {
|
|
1149
|
-
if (options.auto === false)
|
|
1207
|
+
if (options.auto === false) bad(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config, or allow auto-detect)`);
|
|
1150
1208
|
config = autoConfig(repo); // zero-config: gate the detected test command in process
|
|
1151
1209
|
}
|
|
1152
1210
|
|
|
@@ -1198,7 +1256,8 @@ export function conformsToContract(report) {
|
|
|
1198
1256
|
if (m.delta !== undefined && !numOrNull(m.delta)) E(`${at}.delta must be a number or null`);
|
|
1199
1257
|
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)`);
|
|
1200
1258
|
if (m.confidence !== undefined && typeof m.confidence !== 'string') E(`${at}.confidence must be a string`);
|
|
1201
|
-
if (m.gamed !== undefined && typeof m.gamed !== 'boolean') E(`${at}.gamed must be a boolean`);
|
|
1259
|
+
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 !== true) E(`${at}.unprovable must be true when present`);
|
|
1202
1261
|
if (m.allowed !== undefined && typeof m.allowed !== 'boolean') E(`${at}.allowed must be a boolean`);
|
|
1203
1262
|
if (m.sourceOnly !== undefined && !numOrNull(m.sourceOnly)) E(`${at}.sourceOnly must be a number or null`);
|
|
1204
1263
|
if (m.retained !== undefined && !numOrNull(m.retained)) E(`${at}.retained must be a number or null`);
|
|
@@ -1235,6 +1294,8 @@ export function conformsToContract(report) {
|
|
|
1235
1294
|
E("verdict 'pass' but a guarded metric regressed (guard:true, ok:false)");
|
|
1236
1295
|
if (report.verdict === 'pass' && report.metrics.some((m) => m.gamed === true))
|
|
1237
1296
|
E("verdict 'pass' but a metric is gamed:true");
|
|
1297
|
+
if (report.verdict === 'pass' && report.metrics.some((m) => m.unprovable === true))
|
|
1298
|
+
E("verdict 'pass' but a metric is unprovable (its source-only re-run could not be applied)");
|
|
1238
1299
|
if (report.verdict === 'pass' && gateWeakened)
|
|
1239
1300
|
E("verdict 'pass' but report.gate.weakened is non-empty (the gate was weakened)");
|
|
1240
1301
|
if (report.verdict === 'gamed' && !report.metrics.some((m) => m.gamed === true) && !gateWeakened)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
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": {
|