promptwheel 0.7.6 → 0.7.7
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 +8 -0
- package/bin/promptwheel.mjs +35 -14
- 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 {
|
|
@@ -236,6 +238,12 @@ export declare function verdictExitCode(verdict: Verdict | string): 0 | 1 | 2 |
|
|
|
236
238
|
* never calls `process.exit`; throws a plain `Error` on a precondition failure (not a git
|
|
237
239
|
* repository, `auto: false` with no config, a `config` without a non-empty `metrics` array).
|
|
238
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* Runs the gate in process and returns the report. Never prints and never exits the process: a
|
|
243
|
+
* precondition failure (not a git repository, a missing, malformed or cyclic config, a missing
|
|
244
|
+
* `extends` target, no commits yet) throws an Error whose `name` is `'ConfigError'` and whose
|
|
245
|
+
* `exitCode` is 64, the code the CLI exits with for the same condition.
|
|
246
|
+
*/
|
|
239
247
|
export declare function check(options?: CheckOptions): Report;
|
|
240
248
|
|
|
241
249
|
/**
|
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,
|
|
@@ -1273,7 +1290,7 @@ function conformance(argv) {
|
|
|
1273
1290
|
process.exit(1);
|
|
1274
1291
|
}
|
|
1275
1292
|
|
|
1276
|
-
function
|
|
1293
|
+
function dispatch() {
|
|
1277
1294
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
1278
1295
|
if (cmd === '--version' || cmd === '-v') { console.log(TOOL_VERSION ?? 'unknown'); process.exit(0); }
|
|
1279
1296
|
if (cmd === 'run') run(rest);
|
|
@@ -1315,4 +1332,8 @@ function main() {
|
|
|
1315
1332
|
// run the CLI only when invoked directly (resolves symlinks so the npm bin still works)
|
|
1316
1333
|
let isMain = false;
|
|
1317
1334
|
try { isMain = !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { /* not main */ }
|
|
1335
|
+
function main() {
|
|
1336
|
+
try { dispatch(); }
|
|
1337
|
+
catch (e) { if (e && e.name === 'ConfigError') die(e.message); throw e; }
|
|
1338
|
+
}
|
|
1318
1339
|
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.7",
|
|
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": {
|