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 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, so a pass cannot be certified). An agent reads the
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
 
@@ -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 did not apply. Absent without gaming detection or when the metric did not improve. */
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
- * Run the gate in process and return the Section 2 verdict object (identical to `run --json`).
237
- * Measures only in throwaway git worktrees, never the caller's working tree. Never prints and
238
- * never calls `process.exit`; throws a plain `Error` on a precondition failure (not a git
239
- * repository, `auto: false` with no config, a `config` without a non-empty `metrics` array).
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 report. Never prints and never exits the process: a
243
- * precondition failure (not a git repository, a missing, malformed or cyclic config, a missing
244
- * `extends` target, no commits yet) throws an Error whose `name` is `'ConfigError'` and whose
245
- * `exitCode` is 64, the code the CLI exits with for the same condition.
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;
@@ -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
- const head = git(['rev-parse', 'HEAD'], repo);
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
- /(^|\/)(eval|grader|score)[^/]*\.[cm]?[jt]s$/i,
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 `metric` in a worktree at `base` with ONLY the source slice of base→head applied.
336
- function measureSourceOnly(repo, base, head, metric, cfg, repeat) {
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) return { samples: [], hadSourceChange: false }; // a "win" with zero source edits = goalposts moved
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
- const patch = git(['diff', base, head, '--', ...source], repo);
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 + '\n', encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] }); };
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 { return { samples: [], hadSourceChange: true, applyFailed: true }; } }
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 samples = [];
353
- for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, metric, env));
354
- return { samples, hadSourceChange: true };
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
- if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly, inconclusive' };
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(), cutoff = Date.now() - 3600_000;
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; // a live/registered worktree, leave it
430
- try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
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
- const j = judgeGaming(m, measureSourceOnly(repo, base, head, cm, cfg, repeat), cm.gamingThreshold ?? cfg.gamingThreshold ?? 0.5);
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 failed = metrics.some((m) => m.guard && !m.ok);
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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 git(['rev-parse', '--show-toplevel'], process.cwd()); } catch { return process.cwd(); } })();
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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; it throws a plain Error on a precondition failure (not a git repo, or a
1124
- // missing / invalid config). Reproducible from (base, head, config) alone.
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
- let repo;
1139
- try {
1140
- repo = options.repo ? realpathSync(options.repo)
1141
- : git(['rev-parse', '--show-toplevel'], process.cwd());
1142
- } catch { throw new Error(`promptwheel check: not inside a git repository (${options.repo || process.cwd()})`); }
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
- throw new Error('promptwheel check: options.config.metrics must be a non-empty array');
1205
+ bad('promptwheel check: options.config.metrics must be a non-empty array');
1148
1206
  } else if (!hasConfigFile(repo)) {
1149
- if (options.auto === false) throw new Error(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config, or allow auto-detect)`);
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.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": {