promptwheel 0.8.1 → 0.8.3

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, resolve } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  // ---------------------------------------------------------------------------
@@ -36,6 +36,19 @@ const spread = (xs) => { const a = xs.filter((x) => x != null); return a.length
36
36
  // repo is still measuring in (a long gate elsewhere must not lose its worktree mid-run)
37
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
38
  const tmpPrefix = (repo, kind) => `${kind}-${tmpOwnerTag(repo)}-`;
39
+ // where the engine makes its clean copies: the system temp folder, or, where that is read-only (Claude Code's sandbox,
40
+ // a locked-down CI runner), a folder inside the repo's own .git, which git status never shows
41
+ const tmpBases = new Map();
42
+ function tmpBase(repo) {
43
+ if (tmpBases.has(repo)) return tmpBases.get(repo);
44
+ let base = tmpdir();
45
+ try { rmSync(mkdtempSync(join(base, 'pw-probe-')), { recursive: true, force: true }); }
46
+ catch {
47
+ let gd = ''; try { gd = resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-common-dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { /* */ }
48
+ if (gd) { base = join(gd, 'promptwheel-tmp'); try { mkdirSync(base, { recursive: true }); } catch { base = tmpdir(); } }
49
+ }
50
+ tmpBases.set(repo, base); return base;
51
+ }
39
52
  // exactly the names mkdtemp makes from tmpPrefix: the kind, the 8-hex owner tag, then mkdtemp's 6 random
40
53
  // characters. Self-heal removes nothing else, so a folder that only starts with "promptwheel-" is never ours.
41
54
  const ENGINE_TMP = /^(promptwheel-src|promptwheel|pw-idx)-([0-9a-f]{8})-[A-Za-z0-9]{6}$/;
@@ -147,15 +160,26 @@ function resolveBase(repo, base) {
147
160
  // test that ran and failed
148
161
  const exitCodes = new WeakMap();
149
162
  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) {
163
+ // Why a metric's command failed, read from its output: a dependency the clean copy does not have reads differently
164
+ // from tests that simply fail. A hint for the person, never part of a verdict.
165
+ 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;
166
+ function whyFailed(text) {
167
+ const m = String(text || '').replace(/\x1b\[[0-9;]*m/g, '').match(MISSING_DEP);
168
+ return m ? { cause: 'missing-dependency', detail: m[0].trim().slice(0, 200) } : { cause: 'failing' };
169
+ }
170
+ const failNotes = new WeakMap(); // samples -> why the last failing run failed
171
+ function runMetric(cwd, m, env, codes, notes) {
151
172
  let stdout = '', code = 0, status = 0;
152
173
  // Never leak the harness's own node:test context into the measured gate: a metric like
153
174
  // `node --test` would see NODE_TEST_CONTEXT, act as a child reporter, and report a bogus
154
175
  // exit code (so the gate can't be trusted when PromptWheel itself runs under a test runner).
155
176
  const childEnv = { ...(env || process.env) }; delete childEnv.NODE_TEST_CONTEXT;
156
177
  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 || ''}`; }
178
+ // maxBuffer: the default is 1 MB, and a suite that prints more than that was killed and read as failing. A real
179
+ // repo found it (tap prints every assertion when its output is a pipe): the check said "your tests fail" about
180
+ // a suite that passes.
181
+ stdout = execSync(m.cmd, { cwd, env: childEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (m.timeoutSec ?? 300) * 1000, maxBuffer: 1024 * 1024 * 1024 });
182
+ } 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
183
  if (codes) codes.push(status);
160
184
  return extract(stdout, code, m.extract, m.allowNonzero);
161
185
  }
@@ -186,11 +210,62 @@ function extract(stdout, code, mode, allowNonzero) {
186
210
  // Python run can put the measured ref FIRST on PYTHONPATH). Ecosystem knowledge lives in config,
187
211
  // not here, the engine stays ecosystem-agnostic.
188
212
  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 */ }
213
+ // Every folder that has a tracked package.json and a node_modules of its own: the workspaces of a monorepo, whichever
214
+ // tool laid them out. Asked of git, so nothing walks the tree.
215
+ function workspaceModuleDirs(repo) {
216
+ let files = '';
217
+ try { files = execFileSync('git', ['ls-files', '-z', '--', '*/package.json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }); } catch { return []; }
218
+ const out = [];
219
+ for (const f of files.split('\0')) {
220
+ if (!f || f.split('/').includes('node_modules')) continue;
221
+ const d = join(dirname(f), 'node_modules');
222
+ if (existsSync(join(repo, d))) out.push(d);
223
+ if (out.length >= 500) break;
224
+ }
225
+ return out;
226
+ }
227
+ // A node_modules folder in a workspaces repo holds links back into the repo: npm and yarn link each workspace package
228
+ // from the root, pnpm from the workspace that depends on it. One symlink to the real folder would make a test in the
229
+ // worktree import a sibling package from the working tree, at both refs, so a regression there would never show. Such
230
+ // a folder is rebuilt in the worktree instead: each entry linked on its own, and an entry that points back into the
231
+ // repo pointed at the same path in the worktree. A folder with no such entry, in a repo pnpm did not lay out, keeps
232
+ // the single link it always had.
233
+ function linkModules(repo, wt, d) {
234
+ const src = join(repo, d), dst = join(wt, d);
235
+ const entries = [];
236
+ try {
237
+ for (const e of readdirSync(src, { withFileTypes: true })) {
238
+ 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]); }
239
+ else entries.push([e.name, e]);
240
+ }
241
+ } catch { return; }
242
+ const back = (name, e) => {
243
+ if (!e.isSymbolicLink()) return null;
244
+ 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; }
245
+ };
246
+ const links = entries.map(([name, e]) => [name, back(name, e), e]);
247
+ // pnpm refuses a node_modules, or its own state folder inside it, that is a symlink: its scripts stop at once
248
+ const pnpm = entries.some(([name]) => name === '.pnpm' || name === '.modules.yaml');
249
+ try {
250
+ if (!pnpm && !links.some(([, rel]) => rel)) { symlinkSync(src, dst); return; }
251
+ mkdirSync(dst, { recursive: true });
252
+ for (const [name, rel, e] of links) {
253
+ // a tool's own state or cache folder (pnpm's task-run state, .cache, .vite) is written to, so each worktree gets
254
+ // its own, made by the tool. The store (.pnpm) and the executables (.bin) are read, so they are linked.
255
+ if (name.startsWith('.') && name !== '.bin' && name !== '.pnpm' && e.isDirectory()) continue;
256
+ if (name.includes(sep)) mkdirSync(dirname(join(dst, name)), { recursive: true });
257
+ symlinkSync(rel ? join(wt, rel) : join(src, name), join(dst, name));
193
258
  }
259
+ } catch { /* best effort */ }
260
+ }
261
+ function bridgeEnv(repo, wt, cfg) {
262
+ let root = repo; try { root = realpathSync(repo); } catch { /* keep the path as given */ }
263
+ // with nothing declared, a Node repo links its root node_modules and each workspace's own
264
+ const dirs = (Array.isArray(cfg.linkDirs) || cfg.linkNodeModules === false) ? linkDirsOf(cfg) : ['node_modules', ...workspaceModuleDirs(root)];
265
+ for (const d of dirs) {
266
+ if (!existsSync(join(root, d)) || existsSync(join(wt, d)) || !existsSync(dirname(join(wt, d)))) continue;
267
+ if (basename(d) === 'node_modules') linkModules(root, wt, d);
268
+ else { try { symlinkSync(join(root, d), join(wt, d)); } catch { /* best effort */ } }
194
269
  }
195
270
  if (!cfg.env) return process.env;
196
271
  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,23 +273,31 @@ function bridgeEnv(repo, wt, cfg) {
198
273
  // an optional per-ref build/install (e.g. `npm run build`, `pip install -e .`). Best-effort: a
199
274
  // failed setup just leaves the metric inert → INCONCLUSIVE (never a false green). Run by the
200
275
  // caller AFTER any source patch, so it builds the tree actually being measured.
276
+ // Returns nothing when setup worked, or { exit, lastLine } when it did not, so the report can say so.
201
277
  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 */ }
278
+ if (!cfg.setup) return undefined;
279
+ try { execSync(cfg.setup, { cwd: wt, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 300_000, maxBuffer: 1024 * 1024 * 1024 }); return undefined; }
280
+ catch (e) {
281
+ const lines = `${e.stdout || ''}\n${e.stderr || ''}`.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
282
+ return { exit: e.status ?? null, lastLine: (lines[lines.length - 1] || '').slice(0, 200) }; // the metric goes inert → inconclusive
283
+ }
204
284
  }
285
+ const setupFails = new WeakMap(); // a measureAt result -> how its setup failed
205
286
 
206
287
  // measure every metric `repeat` times at a ref, in a throwaway worktree (never touches your tree)
207
288
  function measureAt(repo, ref, cfg, repeat) {
208
- const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel')));
289
+ const wt = mkdtempSync(join(tmpBase(repo), tmpPrefix(repo, 'promptwheel')));
209
290
  git(['worktree', 'add', '--quiet', '--detach', wt, ref], repo);
210
291
  try {
211
292
  const env = bridgeEnv(repo, wt, cfg);
212
- runSetup(wt, cfg, env);
293
+ const setupFailed = runSetup(wt, cfg, env);
213
294
  const out = {};
295
+ if (setupFailed) setupFails.set(out, setupFailed);
214
296
  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));
297
+ const samples = [], codes = [], notes = [];
298
+ for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, m, env, codes, notes));
217
299
  exitCodes.set(samples, codes);
300
+ if (notes.length) failNotes.set(samples, notes[notes.length - 1]);
218
301
  out[m.name] = samples;
219
302
  }
220
303
  return out;
@@ -419,7 +502,7 @@ function measureSourceOnlyAll(repo, base, head, defs, cfg, repeat) {
419
502
  if (!defs.length) return out;
420
503
  const { source } = changedSourcePaths(repo, base, head, evaluatorMatcher(cfg));
421
504
  if (!source.length) { for (const m of defs) out[m.name] = { samples: [], hadSourceChange: false }; return out; } // a "win" with zero source edits = goalposts moved
422
- const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel-src')));
505
+ const wt = mkdtempSync(join(tmpBase(repo), tmpPrefix(repo, 'promptwheel-src')));
423
506
  git(['worktree', 'add', '--quiet', '--detach', wt, base], repo);
424
507
  try {
425
508
  const env = bridgeEnv(repo, wt, cfg);
@@ -480,7 +563,7 @@ function judgeGaming(m, srcResult, threshold = 0.5) {
480
563
  // touching the real index or files, so a gate sees a file an agent just added.
481
564
  // Returns 'HEAD' when nothing changed.
482
565
  function workingSnapshot(repo) {
483
- const idxDir = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'pw-idx')));
566
+ const idxDir = mkdtempSync(join(tmpBase(repo), tmpPrefix(repo, 'pw-idx')));
484
567
  const env = {
485
568
  ...process.env, GIT_INDEX_FILE: join(idxDir, 'index'),
486
569
  GIT_AUTHOR_NAME: 'promptwheel', GIT_AUTHOR_EMAIL: 'promptwheel@local',
@@ -519,7 +602,7 @@ function selfHeal(repo) {
519
602
  git(['worktree', 'list', '--porcelain'], repo)
520
603
  .split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice(9).trim()),
521
604
  );
522
- const tmp = tmpdir(), now = Date.now(), mine = tmpOwnerTag(repo);
605
+ const tmp = tmpBase(repo), now = Date.now(), mine = tmpOwnerTag(repo);
523
606
  for (const name of readdirSync(tmp)) {
524
607
  const made = ENGINE_TMP.exec(name);
525
608
  if (!made) continue; // not a name this engine generates (worktrees + temp-index dirs): never removed
@@ -883,6 +966,8 @@ function gate(repo, opts) {
883
966
  if (ev.inert && m.cmd !== PLACEHOLDER_TEST) {
884
967
  const nb = neverRan(before[m.name]), na = neverRan(after[m.name]);
885
968
  if (!nb || !na) ev.ran = !nb && !na ? 'both' : nb ? 'head' : 'base';
969
+ const note = failNotes.get(after[m.name]) || failNotes.get(before[m.name]); // a hint: a missing dependency or tests that fail
970
+ if (note) Object.assign(ev, note);
886
971
  }
887
972
  return { name: m.name, direction: m.direction || 'up', guard: !!m.guard, ...(gamingCheckOff(m, baseGate) ? { tripwire: true } : {}), ...ev };
888
973
  });
@@ -954,6 +1039,9 @@ function gate(repo, opts) {
954
1039
  ...(gateInfo ? { gate: gateInfo } : {}),
955
1040
  ...(allowed ? { allowed } : {}),
956
1041
  };
1042
+ // a setup command that failed, at the ref it failed at: the usual reason a metric could not be measured
1043
+ const sb = setupFails.get(before), sa = setupFails.get(after);
1044
+ if (sb || sa) report.setup = { ...(sb ? { base: sb } : {}), ...(sa ? { head: sa } : {}) };
957
1045
  // provenance receipt: makes the verdict tamper-evident + reproducible ("re-run and get the same
958
1046
  // verdict is checkable"). Additive to the record; never changes the verdict.
959
1047
  report.provenance = {
@@ -988,7 +1076,7 @@ const PRESETS = {
988
1076
  'antihack': { desc: 'catch reward-hacking: a target + tripwires; pairs with `run --detect-gaming` (source-only re-run)',
989
1077
  metrics: [
990
1078
  { 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 },
1079
+ { 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
1080
  { 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
1081
  { 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
1082
  { 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 +1267,12 @@ function printHuman(r) {
1179
1267
  const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
1180
1268
  const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
1181
1269
  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`);
1270
+ 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`);
1271
+ 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
1272
  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
1273
  if (m.gamed === true) console.log(` 🚩 GAMED, ${m.gamingReason}`);
1185
1274
  }
1275
+ 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
1276
  if (r.gate && r.gate.weakened) for (const w of r.gate.weakened) console.log(w.change === 'measurement-edited'
1187
1277
  ? ` 🚩 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
1278
  : ` 🚩 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.3",
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": {