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 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 "$AGENT"; do :; done # converges: stops on the first non-zero
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
@@ -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
  /**
@@ -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) { die(`invalid config ${p}: ${e.message}`); }
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)) die(`config inheritance cycle at ${relOf(repo, p)}`);
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)) die(`extends target not found: ${ref} (from ${relOf(repo, p)})`);
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)) die('no promptwheel.config.json, run: promptwheel init (writes one for your stack)');
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) die('config.metrics must be a non-empty array');
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 removed = (Array.isArray(baseMandates) ? baseMandates : []).filter((m) => m && m.name && !headNames.has(m.name));
294
- const mandates = [...headList, ...removed.map((m) => ({ ...m, __removed: true }))];
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
- return out;
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 { die('no commits yet, make an initial commit before gating --working changes'); }
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 main() {
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.6",
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": {