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 +2 -2
- package/bin/promptwheel.d.ts +12 -0
- package/bin/promptwheel.mjs +92 -15
- 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 } 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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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 === '
|
|
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.
|
|
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": {
|