promptwheel 0.7.9 → 0.7.10

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