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 +2 -2
- package/bin/promptwheel.d.ts +12 -0
- package/bin/promptwheel.mjs +109 -19
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
|
package/bin/promptwheel.d.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 === '
|
|
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.
|
|
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": {
|