promptwheel 0.8.1 → 0.8.2

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/README.md CHANGED
@@ -69,7 +69,7 @@ Gaming detection is on by default in `run`; `--no-detect-gaming` gives you the b
69
69
  config, or grader, not the source, so revert and earn it in the source. Exit 1 means a guarded
70
70
  metric regressed, revert that too. Never weaken, skip, or delete a test to go green.
71
71
 
72
- **Two callers, by design.** Your *agent* consumes the verdict as a per-turn signal (`run --working --json`). The *harness* (a Stop-hook, or CI) runs the same check as an audit the agent cannot self-clear, because the check re-proves from the source alone and never asks the code to grade itself. See [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md) for the three places to enforce it.
72
+ **Two callers, by design.** Your *agent* consumes the verdict as a per-turn signal (`run --working --json`). The *harness* (a Stop-hook, or CI) runs the same check as an audit the agent cannot self-clear, because the check re-proves from the source alone and never asks the code to grade itself. See [docs/ENFORCEMENT.md](docs/ENFORCEMENT.md) for the two places to enforce it.
73
73
 
74
74
  ## Config, `promptwheel.config.json`
75
75
 
@@ -98,7 +98,7 @@ Gaming detection is on by default in `run`; `--no-detect-gaming` gives you the b
98
98
 
99
99
  Dependency and environment keys (config-level; `init` writes stack defaults):
100
100
 
101
- - **linkDirs**, dirs to symlink from your checkout into each measuring worktree, for deps that live outside git. Default `["node_modules"]`; `init` writes `[".venv"]` for Python and `[]` for Rust (`cargo test` writes to `target/`, so it is built fresh in the worktree rather than shared across refs). `"linkNodeModules": false` still works as shorthand for "link nothing".
101
+ - **linkDirs**, dirs to symlink from your checkout into each measuring worktree, for deps that live outside git. With nothing set, a Node repo links its root `node_modules` and the `node_modules` of every workspace (any folder with a tracked `package.json`), so npm, yarn and pnpm workspaces are measured with no config. A `node_modules` that pnpm laid out, or that holds workspace links, is rebuilt in the worktree entry by entry, because pnpm refuses a symlinked one, and a tool's own state or cache folder inside it is left out. A link from `node_modules` back into the repo, which is how those tools wire a sibling package, is pointed at the worktree, so a test that imports a sibling package reads the ref being measured. `init` writes `[".venv"]` for Python and `[]` for Rust (`cargo test` writes to `target/`, so it is built fresh in the worktree rather than shared across refs). `"linkNodeModules": false` still works as shorthand for "link nothing".
102
102
  - **env**, environment variables for every metric command; `{wt}` is substituted with the worktree path. Python configs get `"PYTHONPATH": "{wt}/src:{wt}"` so the *measured ref* is imported, not an editable install of your original checkout.
103
103
  - **setup**, an optional command run once per ref before metrics (for example `npm run build` or `pip install -e .`), after any source patch, so a build-gated suite measures the ref it is actually on.
104
104
 
@@ -146,6 +146,10 @@ export interface MetricResult extends Evaluation {
146
146
  guard: boolean;
147
147
  /** With `inert`: where the command ran and failed (`both` refs, or only `base` or `head`). Absent when it ran at neither: exit 126 or 127, a timeout, or the placeholder command `init` writes. */
148
148
  ran?: 'both' | 'base' | 'head';
149
+ /** With `inert`: a hint read from the command's output. `missing-dependency` when it names a module, package or command the clean copy does not have, else `failing`. Never part of a verdict. */
150
+ cause?: 'missing-dependency' | 'failing' | 'too-much-output';
151
+ /** With `cause: 'missing-dependency'`: the line that names what is missing, at most 200 characters. */
152
+ detail?: string;
149
153
  /** `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. */
150
154
  gamed?: boolean | null;
151
155
  /** `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`). */
@@ -234,9 +238,17 @@ export interface Report {
234
238
  mandates?: MandateViolation[];
235
239
  gate?: GateRecord;
236
240
  allowed?: AllowedRecord;
241
+ /** Present only when the `setup` command failed: at which ref, with what exit code, and its last output line. */
242
+ setup?: { base?: SetupFailure; head?: SetupFailure };
237
243
  provenance: Provenance;
238
244
  }
239
245
 
246
+ export interface SetupFailure {
247
+ /** The setup command's exit code, or `null` when it was killed (a timeout). */
248
+ exit: number | null;
249
+ lastLine: string;
250
+ }
251
+
240
252
  // ---------------------------------------------------------------------------
241
253
  // Public library API (stable across 0.x minors)
242
254
  // ---------------------------------------------------------------------------
@@ -16,7 +16,7 @@ import { execSync, execFileSync } from 'node:child_process';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, realpathSync, readdirSync, lstatSync, copyFileSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
- import { join, dirname, isAbsolute, relative } from 'node:path';
19
+ import { join, dirname, basename, isAbsolute, relative, sep } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  // ---------------------------------------------------------------------------
@@ -147,15 +147,26 @@ function resolveBase(repo, base) {
147
147
  // test that ran and failed
148
148
  const exitCodes = new WeakMap();
149
149
  const neverRan = (samples) => { const c = exitCodes.get(samples) || []; return c.length > 0 && c.every((x) => x == null || x === 126 || x === 127); };
150
- function runMetric(cwd, m, env, codes) {
150
+ // Why a metric's command failed, read from its output: a dependency the clean copy does not have reads differently
151
+ // from tests that simply fail. A hint for the person, never part of a verdict.
152
+ const MISSING_DEP = /Cannot find (?:module|package) ['"][^'"\n]+['"]|ERR_MODULE_NOT_FOUND[^\n]*|ModuleNotFoundError: [^\n]+|No module named [^\n]+|error TS2307: [^\n]+|[^\n:]*: (?:command )?not found[^\n]*|can't find crate[^\n]*|cannot find package [^\n]+/i;
153
+ function whyFailed(text) {
154
+ const m = String(text || '').replace(/\x1b\[[0-9;]*m/g, '').match(MISSING_DEP);
155
+ return m ? { cause: 'missing-dependency', detail: m[0].trim().slice(0, 200) } : { cause: 'failing' };
156
+ }
157
+ const failNotes = new WeakMap(); // samples -> why the last failing run failed
158
+ function runMetric(cwd, m, env, codes, notes) {
151
159
  let stdout = '', code = 0, status = 0;
152
160
  // Never leak the harness's own node:test context into the measured gate: a metric like
153
161
  // `node --test` would see NODE_TEST_CONTEXT, act as a child reporter, and report a bogus
154
162
  // exit code (so the gate can't be trusted when PromptWheel itself runs under a test runner).
155
163
  const childEnv = { ...(env || process.env) }; delete childEnv.NODE_TEST_CONTEXT;
156
164
  try {
157
- stdout = execSync(m.cmd, { cwd, env: childEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (m.timeoutSec ?? 300) * 1000 });
158
- } catch (e) { status = e.status ?? null; code = e.status ?? 1; stdout = `${e.stdout || ''}${e.stderr || ''}`; }
165
+ // maxBuffer: the default is 1 MB, and a suite that prints more than that was killed and read as failing. A real
166
+ // repo found it (tap prints every assertion when its output is a pipe): the check said "your tests fail" about
167
+ // a suite that passes.
168
+ stdout = execSync(m.cmd, { cwd, env: childEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (m.timeoutSec ?? 300) * 1000, maxBuffer: 1024 * 1024 * 1024 });
169
+ } catch (e) { status = e.status ?? null; code = e.status ?? 1; stdout = `${e.stdout || ''}${e.stderr || ''}`; if (notes) notes.push(e.code === 'ENOBUFS' ? { cause: 'too-much-output', detail: 'the command printed more than 1 GB, so it was stopped before it finished' } : whyFailed(`${e.stderr || ''}\n${e.stdout || ''}`)); }
159
170
  if (codes) codes.push(status);
160
171
  return extract(stdout, code, m.extract, m.allowNonzero);
161
172
  }
@@ -186,11 +197,62 @@ function extract(stdout, code, mode, allowNonzero) {
186
197
  // Python run can put the measured ref FIRST on PYTHONPATH). Ecosystem knowledge lives in config,
187
198
  // not here, the engine stays ecosystem-agnostic.
188
199
  const linkDirsOf = (cfg) => (Array.isArray(cfg.linkDirs) ? cfg.linkDirs : (cfg.linkNodeModules === false ? [] : ['node_modules']));
189
- function bridgeEnv(repo, wt, cfg) {
190
- for (const d of linkDirsOf(cfg)) {
191
- if (existsSync(join(repo, d)) && !existsSync(join(wt, d))) {
192
- try { symlinkSync(join(repo, d), join(wt, d)); } catch { /* best effort */ }
200
+ // Every folder that has a tracked package.json and a node_modules of its own: the workspaces of a monorepo, whichever
201
+ // tool laid them out. Asked of git, so nothing walks the tree.
202
+ function workspaceModuleDirs(repo) {
203
+ let files = '';
204
+ try { files = execFileSync('git', ['ls-files', '-z', '--', '*/package.json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }); } catch { return []; }
205
+ const out = [];
206
+ for (const f of files.split('\0')) {
207
+ if (!f || f.split('/').includes('node_modules')) continue;
208
+ const d = join(dirname(f), 'node_modules');
209
+ if (existsSync(join(repo, d))) out.push(d);
210
+ if (out.length >= 500) break;
211
+ }
212
+ return out;
213
+ }
214
+ // A node_modules folder in a workspaces repo holds links back into the repo: npm and yarn link each workspace package
215
+ // from the root, pnpm from the workspace that depends on it. One symlink to the real folder would make a test in the
216
+ // worktree import a sibling package from the working tree, at both refs, so a regression there would never show. Such
217
+ // a folder is rebuilt in the worktree instead: each entry linked on its own, and an entry that points back into the
218
+ // repo pointed at the same path in the worktree. A folder with no such entry, in a repo pnpm did not lay out, keeps
219
+ // the single link it always had.
220
+ function linkModules(repo, wt, d) {
221
+ const src = join(repo, d), dst = join(wt, d);
222
+ const entries = [];
223
+ try {
224
+ for (const e of readdirSync(src, { withFileTypes: true })) {
225
+ if (e.name.startsWith('@') && e.isDirectory()) { for (const s of readdirSync(join(src, e.name), { withFileTypes: true })) entries.push([join(e.name, s.name), s]); }
226
+ else entries.push([e.name, e]);
193
227
  }
228
+ } catch { return; }
229
+ const back = (name, e) => {
230
+ if (!e.isSymbolicLink()) return null;
231
+ try { const r = relative(repo, realpathSync(join(src, name))); return r && !r.startsWith('..') && !isAbsolute(r) && !r.split(sep).includes('node_modules') ? r : null; } catch { return null; }
232
+ };
233
+ const links = entries.map(([name, e]) => [name, back(name, e), e]);
234
+ // pnpm refuses a node_modules, or its own state folder inside it, that is a symlink: its scripts stop at once
235
+ const pnpm = entries.some(([name]) => name === '.pnpm' || name === '.modules.yaml');
236
+ try {
237
+ if (!pnpm && !links.some(([, rel]) => rel)) { symlinkSync(src, dst); return; }
238
+ mkdirSync(dst, { recursive: true });
239
+ for (const [name, rel, e] of links) {
240
+ // a tool's own state or cache folder (pnpm's task-run state, .cache, .vite) is written to, so each worktree gets
241
+ // its own, made by the tool. The store (.pnpm) and the executables (.bin) are read, so they are linked.
242
+ if (name.startsWith('.') && name !== '.bin' && name !== '.pnpm' && e.isDirectory()) continue;
243
+ if (name.includes(sep)) mkdirSync(dirname(join(dst, name)), { recursive: true });
244
+ symlinkSync(rel ? join(wt, rel) : join(src, name), join(dst, name));
245
+ }
246
+ } catch { /* best effort */ }
247
+ }
248
+ function bridgeEnv(repo, wt, cfg) {
249
+ let root = repo; try { root = realpathSync(repo); } catch { /* keep the path as given */ }
250
+ // with nothing declared, a Node repo links its root node_modules and each workspace's own
251
+ const dirs = (Array.isArray(cfg.linkDirs) || cfg.linkNodeModules === false) ? linkDirsOf(cfg) : ['node_modules', ...workspaceModuleDirs(root)];
252
+ for (const d of dirs) {
253
+ if (!existsSync(join(root, d)) || existsSync(join(wt, d)) || !existsSync(dirname(join(wt, d)))) continue;
254
+ if (basename(d) === 'node_modules') linkModules(root, wt, d);
255
+ else { try { symlinkSync(join(root, d), join(wt, d)); } catch { /* best effort */ } }
194
256
  }
195
257
  if (!cfg.env) return process.env;
196
258
  return { ...process.env, ...Object.fromEntries(Object.entries(cfg.env).map(([k, v]) => [k, String(v).replaceAll('{wt}', () => wt)])) }; // fn replacement: a literal wt avoids $-pattern interpretation
@@ -198,10 +260,16 @@ function bridgeEnv(repo, wt, cfg) {
198
260
  // an optional per-ref build/install (e.g. `npm run build`, `pip install -e .`). Best-effort: a
199
261
  // failed setup just leaves the metric inert → INCONCLUSIVE (never a false green). Run by the
200
262
  // caller AFTER any source patch, so it builds the tree actually being measured.
263
+ // Returns nothing when setup worked, or { exit, lastLine } when it did not, so the report can say so.
201
264
  function runSetup(wt, cfg, env) {
202
- if (!cfg.setup) return;
203
- try { execSync(cfg.setup, { cwd: wt, env, stdio: 'ignore', timeout: 300_000 }); } catch { /* inert → inconclusive */ }
265
+ if (!cfg.setup) return undefined;
266
+ try { execSync(cfg.setup, { cwd: wt, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 300_000, maxBuffer: 1024 * 1024 * 1024 }); return undefined; }
267
+ catch (e) {
268
+ const lines = `${e.stdout || ''}\n${e.stderr || ''}`.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
269
+ return { exit: e.status ?? null, lastLine: (lines[lines.length - 1] || '').slice(0, 200) }; // the metric goes inert → inconclusive
270
+ }
204
271
  }
272
+ const setupFails = new WeakMap(); // a measureAt result -> how its setup failed
205
273
 
206
274
  // measure every metric `repeat` times at a ref, in a throwaway worktree (never touches your tree)
207
275
  function measureAt(repo, ref, cfg, repeat) {
@@ -209,12 +277,14 @@ function measureAt(repo, ref, cfg, repeat) {
209
277
  git(['worktree', 'add', '--quiet', '--detach', wt, ref], repo);
210
278
  try {
211
279
  const env = bridgeEnv(repo, wt, cfg);
212
- runSetup(wt, cfg, env);
280
+ const setupFailed = runSetup(wt, cfg, env);
213
281
  const out = {};
282
+ if (setupFailed) setupFails.set(out, setupFailed);
214
283
  for (const m of cfg.metrics) {
215
- const samples = [], codes = [];
216
- for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, m, env, codes));
284
+ const samples = [], codes = [], notes = [];
285
+ for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, m, env, codes, notes));
217
286
  exitCodes.set(samples, codes);
287
+ if (notes.length) failNotes.set(samples, notes[notes.length - 1]);
218
288
  out[m.name] = samples;
219
289
  }
220
290
  return out;
@@ -883,6 +953,8 @@ function gate(repo, opts) {
883
953
  if (ev.inert && m.cmd !== PLACEHOLDER_TEST) {
884
954
  const nb = neverRan(before[m.name]), na = neverRan(after[m.name]);
885
955
  if (!nb || !na) ev.ran = !nb && !na ? 'both' : nb ? 'head' : 'base';
956
+ const note = failNotes.get(after[m.name]) || failNotes.get(before[m.name]); // a hint: a missing dependency or tests that fail
957
+ if (note) Object.assign(ev, note);
886
958
  }
887
959
  return { name: m.name, direction: m.direction || 'up', guard: !!m.guard, ...(gamingCheckOff(m, baseGate) ? { tripwire: true } : {}), ...ev };
888
960
  });
@@ -954,6 +1026,9 @@ function gate(repo, opts) {
954
1026
  ...(gateInfo ? { gate: gateInfo } : {}),
955
1027
  ...(allowed ? { allowed } : {}),
956
1028
  };
1029
+ // a setup command that failed, at the ref it failed at: the usual reason a metric could not be measured
1030
+ const sb = setupFails.get(before), sa = setupFails.get(after);
1031
+ if (sb || sa) report.setup = { ...(sb ? { base: sb } : {}), ...(sa ? { head: sa } : {}) };
957
1032
  // provenance receipt: makes the verdict tamper-evident + reproducible ("re-run and get the same
958
1033
  // verdict is checkable"). Additive to the record; never changes the verdict.
959
1034
  report.provenance = {
@@ -988,7 +1063,7 @@ const PRESETS = {
988
1063
  'antihack': { desc: 'catch reward-hacking: a target + tripwires; pairs with `run --detect-gaming` (source-only re-run)',
989
1064
  metrics: [
990
1065
  { name: 'tests_pass', cmd: '__TESTCMD__', extract: 'exit', direction: 'pass', guard: true },
991
- { name: 'test_count', cmd: 'grep -rIoE "\\b(it|test|describe) ?\\(|def test_" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
1066
+ { name: 'test_count', cmd: 'grep -rIoE "\\b(it|test|describe) ?\\(|def test_" --exclude-dir=node_modules --exclude-dir=.promptwheel --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
992
1067
  { name: 'skipped_tests', cmd: 'grep -rIoE "\\.(skip|only) ?\\(|xit ?\\(|@pytest\\.mark\\.skip" --exclude-dir=node_modules --exclude-dir=.promptwheel --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
993
1068
  { name: 'suppressions', cmd: 'grep -rIoE "eslint-disable|@ts-(ignore|nocheck)|# ?type: ?ignore|# ?noqa|# ?nosec|//nolint|#!?\\[allow\\(" --exclude-dir=node_modules --exclude-dir=.promptwheel --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
994
1069
  { name: 'assertions', cmd: 'grep -rIoE "expect ?\\(|\\bassert|\\bt\\.(is|deepEqual|throws|truthy|falsy|not|ok) ?\\(" --exclude-dir=node_modules --exclude-dir=.promptwheel --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
@@ -1179,10 +1254,12 @@ function printHuman(r) {
1179
1254
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
1180
1255
  const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
1181
1256
  console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
1182
- if (m.inert && m.ran === 'both') console.log(` ⚠ "${m.name}" ran and failed before and after this change, so it guards nothing here. Fix the failing tests, or the command, before trusting a pass`);
1257
+ if (m.inert && m.ran && m.cause === 'missing-dependency') console.log(` ⚠ "${m.name}" could not run in the clean copy at either ref: ${m.detail}. Link or install what it needs (linkDirs or setup in promptwheel.config.json), then check again`);
1258
+ else if (m.inert && m.ran === 'both') console.log(` ⚠ "${m.name}" ran and failed before and after this change, so it guards nothing here. Fix the failing tests, or the command, before trusting a pass`);
1183
1259
  else if (m.inert) console.log(` ⚠ "${m.name}" ${m.ran ? `ran and failed at the ${m.ran} ref and did not run at the other` : 'never ran (same value at both refs)'}: its command needs a build/deps first, or is not your real test command. Fix: promptwheel init, or set setup/linkDirs in promptwheel.config.json`);
1184
1260
  if (m.gamed === true) console.log(` 🚩 GAMED, ${m.gamingReason}`);
1185
1261
  }
1262
+ if (r.setup) { const at = Object.keys(r.setup), f = r.setup[at[0]]; console.log(` ⚠ setup failed (exit ${f.exit}) at the ${at.join(' and ')} ref${at.length > 1 ? 's' : ''}${f.lastLine ? ': ' + f.lastLine : ''}`); }
1186
1263
  if (r.gate && r.gate.weakened) for (const w of r.gate.weakened) console.log(w.change === 'measurement-edited'
1187
1264
  ? ` 🚩 GATE WEAKENED, how the guard "${w.name}" is measured (setup, env or linked dirs) was edited in this change, and measured the way it was before it would have ${wouldHave(w.status)} at head (goalpost moved, not earned)`
1188
1265
  : ` 🚩 GATE WEAKENED, the guard "${w.name}" was ${w.change === 'guard-removed' ? 'removed' : w.change === 'guard-edited' ? 'edited' : 'disabled'} in this change and it would have ${wouldHave(w.status)} at head (goalpost moved, not earned)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "A deterministic, zero-LLM check for your coding loop. A win must survive a re-run from the agent's source edits alone, so a test that went green by editing the test reads GAMED. Runs in CI or a Claude Code plugin.",
5
5
  "type": "module",
6
6
  "bin": {