promptwheel 0.7.6 → 0.7.8
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 +1 -1
- package/bin/promptwheel.d.ts +7 -3
- package/bin/promptwheel.mjs +42 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ npx promptwheel init --list # presets: tests-pass · lint · bundl
|
|
|
59
59
|
# the loop: run any agent/script, keep the change ONLY if a metric improved, else revert
|
|
60
60
|
npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
|
|
61
61
|
# exit 0 = kept a real win · 1 = guarded regression (reverted) · 2 = gamed (reverted) · 3 = plateau / inconclusive (reverted) · add --json
|
|
62
|
-
while npx promptwheel improve --attempt "
|
|
62
|
+
while npx promptwheel improve --attempt "<your agent command>"; do :; done # converges: stops on the first non-zero
|
|
63
63
|
|
|
64
64
|
npx promptwheel insights # which metrics actually respond in this repo (counts from .promptwheel/outcomes.jsonl)
|
|
65
65
|
npx promptwheel guards # the effective guardrails, including ones inherited through extends
|
package/bin/promptwheel.d.ts
CHANGED
|
@@ -156,6 +156,8 @@ export interface MandateViolation {
|
|
|
156
156
|
files: string[];
|
|
157
157
|
/** The mandate was deleted in this same change; it stays in force for the change that removes it. */
|
|
158
158
|
removed?: true;
|
|
159
|
+
/** The mandate's paths were edited in this same change; the version before the change stays in force for the change that edits it. */
|
|
160
|
+
edited?: true;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
export interface GateWeakening {
|
|
@@ -231,10 +233,12 @@ export declare const DIRECTIONS: readonly ['up', 'down', 'pass'];
|
|
|
231
233
|
export declare function verdictExitCode(verdict: Verdict | string): 0 | 1 | 2 | 3;
|
|
232
234
|
|
|
233
235
|
/**
|
|
234
|
-
*
|
|
236
|
+
* Runs the gate in process and returns the Section 2 verdict object (identical to `run --json`).
|
|
235
237
|
* Measures only in throwaway git worktrees, never the caller's working tree. Never prints and
|
|
236
|
-
* never calls `process.exit
|
|
237
|
-
*
|
|
238
|
+
* never calls `process.exit`: every precondition failure (not a git repository, `auto: false`
|
|
239
|
+
* with no config, a `config` without a non-empty `metrics` array, a malformed or cyclic config,
|
|
240
|
+
* a missing `extends` target, no commits yet) throws an Error whose `name` is `'ConfigError'`
|
|
241
|
+
* and whose `exitCode` is 64, the code the CLI exits with for the same condition.
|
|
238
242
|
*/
|
|
239
243
|
export declare function check(options?: CheckOptions): Report;
|
|
240
244
|
|
package/bin/promptwheel.mjs
CHANGED
|
@@ -35,10 +35,13 @@ const spread = (xs) => { const a = xs.filter((x) => x != null); return a.length
|
|
|
35
35
|
// (0 pass, 1 fail, 2 gamed, 3 inconclusive): in CI a config typo must not read as GAMED.
|
|
36
36
|
const EX_USAGE = 64;
|
|
37
37
|
function die(msg) { console.error(msg); process.exit(EX_USAGE); }
|
|
38
|
+
// a config or precondition error: thrown, so the library path (check()) surfaces it to the caller
|
|
39
|
+
// instead of exiting the host process; the CLI catches it in main() and exits EX_USAGE.
|
|
40
|
+
function bad(msg) { const e = new Error(msg); e.name = 'ConfigError'; e.exitCode = EX_USAGE; throw e; }
|
|
38
41
|
|
|
39
42
|
function readConfigFile(p) {
|
|
40
43
|
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
41
|
-
catch (e) {
|
|
44
|
+
catch (e) { bad(`invalid config ${p}: ${e.message}`); }
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const relOf = (repo, p) => { try { return relative(repo, p) || p; } catch { return p; } };
|
|
@@ -48,7 +51,7 @@ const relOf = (repo, p) => { try { return relative(repo, p) || p; } catch { retu
|
|
|
48
51
|
// a repo INHERITS their guardrails, and a local metric of the same name overrides the inherited one.
|
|
49
52
|
function resolveConfig(p, repo, label, seen) {
|
|
50
53
|
const abs = realpathSync(p);
|
|
51
|
-
if (seen.has(abs))
|
|
54
|
+
if (seen.has(abs)) bad(`config inheritance cycle at ${relOf(repo, p)}`);
|
|
52
55
|
seen.add(abs);
|
|
53
56
|
try {
|
|
54
57
|
const cfg = readConfigFile(p);
|
|
@@ -57,7 +60,7 @@ function resolveConfig(p, repo, label, seen) {
|
|
|
57
60
|
const scalars = {};
|
|
58
61
|
for (const ref of [].concat(cfg.extends || [])) {
|
|
59
62
|
const bp = isAbsolute(ref) ? ref : join(dirname(p), ref);
|
|
60
|
-
if (!existsSync(bp))
|
|
63
|
+
if (!existsSync(bp)) bad(`extends target not found: ${ref} (from ${relOf(repo, p)})`);
|
|
61
64
|
const base = resolveConfig(bp, repo, relOf(repo, bp), seen);
|
|
62
65
|
for (const m of base.metrics) byName.set(m.name, m);
|
|
63
66
|
for (const md of (base.mandates || [])) if (md && md.name) mandatesByName.set(md.name, md);
|
|
@@ -76,9 +79,9 @@ function resolveConfig(p, repo, label, seen) {
|
|
|
76
79
|
|
|
77
80
|
function loadConfig(repo) {
|
|
78
81
|
const p = join(repo, 'promptwheel.config.json');
|
|
79
|
-
if (!existsSync(p))
|
|
82
|
+
if (!existsSync(p)) bad('no promptwheel.config.json, run: promptwheel init (writes one for your stack)');
|
|
80
83
|
const cfg = resolveConfig(p, repo, 'local', new Set());
|
|
81
|
-
if (!Array.isArray(cfg.metrics) || cfg.metrics.length === 0)
|
|
84
|
+
if (!Array.isArray(cfg.metrics) || cfg.metrics.length === 0) bad('config.metrics must be a non-empty array');
|
|
82
85
|
return cfg;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -285,13 +288,18 @@ function evaluatorMatcher(cfg) {
|
|
|
285
288
|
// so a parent scope's rule holds for every repo beneath it.
|
|
286
289
|
// The mandates in force are the UNION of the head config's and the base ref's (`baseMandates`,
|
|
287
290
|
// resolved by gateDefAtRef): deleting a mandate in the same change that violates it does not
|
|
288
|
-
// clear it. A mandate removed at head but violated in the diff is reported with `removed: true
|
|
291
|
+
// clear it. A mandate removed at head but violated in the diff is reported with `removed: true`; one
|
|
292
|
+
// whose paths were edited at head (same name, different paths) is tested in BOTH versions and reported
|
|
293
|
+
// with `edited: true`, so narrowing a rule in the change that breaks it does not clear it either.
|
|
289
294
|
// The honest way to retire one is a separate change that only edits the config.
|
|
290
295
|
function checkMandates(repo, base, head, cfg, baseMandates) {
|
|
291
296
|
const headList = Array.isArray(cfg.mandates) ? cfg.mandates : [];
|
|
292
297
|
const headNames = new Set(headList.map((m) => m && m.name).filter(Boolean));
|
|
293
|
-
const
|
|
294
|
-
const
|
|
298
|
+
const key = (m) => JSON.stringify([String(m.name), [...(Array.isArray(m.paths) ? m.paths : [])].filter((x) => typeof x === 'string').sort()]);
|
|
299
|
+
const headKeys = new Set(headList.filter((m) => m && m.name).map(key));
|
|
300
|
+
const kept = (Array.isArray(baseMandates) ? baseMandates : []).filter((m) => m && m.name && !headKeys.has(key(m)))
|
|
301
|
+
.map((m) => (headNames.has(m.name) ? { ...m, __edited: true } : { ...m, __removed: true }));
|
|
302
|
+
const mandates = [...headList, ...kept];
|
|
295
303
|
if (!mandates.length) return [];
|
|
296
304
|
let changed = [];
|
|
297
305
|
try { changed = git(['diff', '--name-only', base, head], repo).split('\n').filter(Boolean); } catch { return []; }
|
|
@@ -301,9 +309,18 @@ function checkMandates(repo, base, head, cfg, baseMandates) {
|
|
|
301
309
|
if (!pats.length) continue;
|
|
302
310
|
const res = pats.map(globToRe);
|
|
303
311
|
const hit = changed.filter((f) => res.some((re) => re.test(f)));
|
|
304
|
-
if (hit.length) out.push({ name: String(m.name || 'mandate'), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: hit.slice(0, 20), ...(m.__removed ? { removed: true } : {}) });
|
|
312
|
+
if (hit.length) out.push({ name: String(m.name || 'mandate'), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: hit.slice(0, 20), ...(m.__removed ? { removed: true } : {}), ...(m.__edited ? { edited: true } : {}) });
|
|
305
313
|
}
|
|
306
|
-
|
|
314
|
+
// one entry per name: the head version and the edited base version of a rule merge (files unioned)
|
|
315
|
+
const merged = [];
|
|
316
|
+
for (const v of out) {
|
|
317
|
+
const prev = merged.find((x) => x.name === v.name);
|
|
318
|
+
if (!prev) { merged.push(v); continue; }
|
|
319
|
+
prev.files = [...new Set([...prev.files, ...v.files])].slice(0, 20);
|
|
320
|
+
if (v.edited) prev.edited = true;
|
|
321
|
+
if (!prev.reason && v.reason) prev.reason = v.reason;
|
|
322
|
+
}
|
|
323
|
+
return merged;
|
|
307
324
|
}
|
|
308
325
|
|
|
309
326
|
// split the agent's changed files into production-source vs test/config/grader/golden.
|
|
@@ -626,7 +643,7 @@ function gate(repo, opts) {
|
|
|
626
643
|
// never touches the real index or working tree. (A loop agent's most common action
|
|
627
644
|
// is to ADD a file; `git stash create` omits untracked, which silently reverted them.)
|
|
628
645
|
try { git(['rev-parse', '--verify', 'HEAD'], repo); }
|
|
629
|
-
catch {
|
|
646
|
+
catch { bad('no commits yet, make an initial commit before gating --working changes'); }
|
|
630
647
|
base = 'HEAD';
|
|
631
648
|
head = workingSnapshot(repo);
|
|
632
649
|
} else {
|
|
@@ -1006,7 +1023,7 @@ const short = (repo, ref) => { try { return git(['rev-parse', '--short', ref], r
|
|
|
1006
1023
|
function printHuman(r) {
|
|
1007
1024
|
const arrowFor = (m) => (m.delta == null ? '?' : m.delta > 0 ? '▲' : m.delta < 0 ? '▼' : '=');
|
|
1008
1025
|
console.log(`\nPromptWheel ${r.base} → ${r.head}${r.repeat > 1 ? ` (×${r.repeat})` : ''}\n`);
|
|
1009
|
-
if (r.mandates) for (const md of r.mandates) console.log(` ⛔ MANDATE "${md.name}" violated${md.reason ? ': ' + md.reason : ''} (touched: ${md.files.join(', ')})${md.removed ? ' [deleted in this same change; a mandate stays in force for the change that removes it]' : ''}`);
|
|
1026
|
+
if (r.mandates) for (const md of r.mandates) console.log(` ⛔ MANDATE "${md.name}" violated${md.reason ? ': ' + md.reason : ''} (touched: ${md.files.join(', ')})${md.removed ? ' [deleted in this same change; a mandate stays in force for the change that removes it]' : md.edited ? ' [edited in this same change; the version before the change stays in force for the change that edits it]' : ''}`);
|
|
1010
1027
|
for (const m of r.metrics) {
|
|
1011
1028
|
const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
|
|
1012
1029
|
const d = m.delta == null ? 'n/a' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
|
|
@@ -1038,7 +1055,7 @@ function renderMarkdown(r) {
|
|
|
1038
1055
|
'',
|
|
1039
1056
|
`\`${r.base} → ${r.head}\` · ${r.mode} mode${r.repeat > 1 ? ` · ×${r.repeat}` : ''}`,
|
|
1040
1057
|
'',
|
|
1041
|
-
...(r.mandates ? [r.mandates.map((md) => `> ⛔ **Mandate violated:** \`${md.name}\`${md.reason ? ': ' + md.reason : ''} (touched \`${md.files.join('`, `')}\`)${md.removed ? ' (deleted in this same change; it stays in force for the change that removes it)' : ''}`).join('\n'), ''] : []),
|
|
1058
|
+
...(r.mandates ? [r.mandates.map((md) => `> ⛔ **Mandate violated:** \`${md.name}\`${md.reason ? ': ' + md.reason : ''} (touched \`${md.files.join('`, `')}\`)${md.removed ? ' (deleted in this same change; it stays in force for the change that removes it)' : md.edited ? ' (edited in this same change; the version before the change stays in force for the change that edits it)' : ''}`).join('\n'), ''] : []),
|
|
1042
1059
|
'| metric | before | after | Δ | status | confidence |',
|
|
1043
1060
|
'|---|--:|--:|--:|---|---|',
|
|
1044
1061
|
rows,
|
|
@@ -1103,8 +1120,9 @@ export function verdictExitCode(verdict) {
|
|
|
1103
1120
|
// Run the gate in process and RETURN the Section 2 verdict object (identical to what
|
|
1104
1121
|
// `run --json` prints). Measurement happens only in throwaway worktrees, never the
|
|
1105
1122
|
// caller's working tree (constitution #4). This function never prints and never calls
|
|
1106
|
-
// process.exit;
|
|
1107
|
-
//
|
|
1123
|
+
// process.exit; every precondition failure (not a git repo, a missing / invalid config, no
|
|
1124
|
+
// commits) throws an Error named ConfigError with exitCode 64. Reproducible from (base, head,
|
|
1125
|
+
// config) alone.
|
|
1108
1126
|
//
|
|
1109
1127
|
// repo string repo path (default: discovered from process.cwd())
|
|
1110
1128
|
// base string base ref (default: merge-base vs origin/main..., else HEAD~1)
|
|
@@ -1122,14 +1140,15 @@ export function check(options = {}) {
|
|
|
1122
1140
|
try {
|
|
1123
1141
|
repo = options.repo ? realpathSync(options.repo)
|
|
1124
1142
|
: git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
1125
|
-
|
|
1143
|
+
git(['rev-parse', '--show-toplevel'], repo); // an existing path that is not a repository fails here
|
|
1144
|
+
} catch { bad(`promptwheel check: not inside a git repository (${options.repo || process.cwd()})`); }
|
|
1126
1145
|
|
|
1127
1146
|
let config = options.config;
|
|
1128
1147
|
if (config !== undefined) {
|
|
1129
1148
|
if (!config || typeof config !== 'object' || !Array.isArray(config.metrics) || config.metrics.length === 0)
|
|
1130
|
-
|
|
1149
|
+
bad('promptwheel check: options.config.metrics must be a non-empty array');
|
|
1131
1150
|
} else if (!hasConfigFile(repo)) {
|
|
1132
|
-
if (options.auto === false)
|
|
1151
|
+
if (options.auto === false) bad(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config, or allow auto-detect)`);
|
|
1133
1152
|
config = autoConfig(repo); // zero-config: gate the detected test command in process
|
|
1134
1153
|
}
|
|
1135
1154
|
|
|
@@ -1273,7 +1292,7 @@ function conformance(argv) {
|
|
|
1273
1292
|
process.exit(1);
|
|
1274
1293
|
}
|
|
1275
1294
|
|
|
1276
|
-
function
|
|
1295
|
+
function dispatch() {
|
|
1277
1296
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
1278
1297
|
if (cmd === '--version' || cmd === '-v') { console.log(TOOL_VERSION ?? 'unknown'); process.exit(0); }
|
|
1279
1298
|
if (cmd === 'run') run(rest);
|
|
@@ -1315,4 +1334,8 @@ function main() {
|
|
|
1315
1334
|
// run the CLI only when invoked directly (resolves symlinks so the npm bin still works)
|
|
1316
1335
|
let isMain = false;
|
|
1317
1336
|
try { isMain = !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { /* not main */ }
|
|
1337
|
+
function main() {
|
|
1338
|
+
try { dispatch(); }
|
|
1339
|
+
catch (e) { if (e && e.name === 'ConfigError') die(e.message); throw e; }
|
|
1340
|
+
}
|
|
1318
1341
|
if (isMain) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"description": "A deterministic, zero-LLM check for your coding loop. It proves every change earned its win by re-running the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook, or import { check } in process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|