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 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
@@ -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.
@@ -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 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
+ /** `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) throws an Error whose `name` is `'ConfigError'`
241
- * and whose `exitCode` is 64, the code the CLI exits with for the same condition.
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;
@@ -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
- const head = git(['rev-parse', 'HEAD'], repo);
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
- /(^|\/)(eval|grader|score)[^/]*\.[cm]?[jt]s$/i,
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 `metric` in a worktree at `base` with ONLY the source slice of base→head applied.
336
- function measureSourceOnly(repo, base, head, metric, cfg, repeat) {
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) return { samples: [], hadSourceChange: false }; // a "win" with zero source edits = goalposts moved
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
- const patch = git(['diff', base, head, '--', ...source], repo);
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 + '\n', encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] }); };
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 { return { samples: [], hadSourceChange: true, applyFailed: true }; } }
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 samples = [];
353
- for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, metric, env));
354
- return { samples, hadSourceChange: true };
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
- if (srcResult.applyFailed) return { gamed: null, sourceOnly: null, retained: null, reason: 'source-only patch did not apply cleanly, inconclusive' };
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 a checkout younger than 1h).
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(), cutoff = Date.now() - 3600_000;
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; // a live/registered worktree, leave it
430
- try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { recursive: true, force: true }); } catch { /* */ }
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
- const j = judgeGaming(m, measureSourceOnly(repo, base, head, cm, cfg, repeat), cm.gamingThreshold ?? cfg.gamingThreshold ?? 0.5);
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
- allowed = { reason: allow.reason, cleared: [...gm.map((m) => m.name), ...(gw ? ['gate'] : [])] };
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 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';
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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 git(['rev-parse', '--show-toplevel'], process.cwd()); } catch { return process.cwd(); } })();
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 = git(['rev-parse', '--show-toplevel'], process.cwd());
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
- let repo;
1140
- try {
1141
- repo = options.repo ? realpathSync(options.repo)
1142
- : git(['rev-parse', '--show-toplevel'], process.cwd());
1143
- git(['rev-parse', '--show-toplevel'], repo); // an existing path that is not a repository fails here
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 [cmd, ...rest] = process.argv.slice(2);
1297
- if (cmd === '--version' || cmd === '-v') { console.log(TOOL_VERSION ?? 'unknown'); process.exit(0); }
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.8",
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": {