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 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 {
@@ -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
- * Run the gate in process and return the Section 2 verdict object (identical to `run --json`).
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`; throws a plain `Error` on a precondition failure (not a git
237
- * repository, `auto: false` with no config, a `config` without a non-empty `metrics` array).
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
 
@@ -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,
@@ -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; it throws a plain Error on a precondition failure (not a git repo, or a
1107
- // missing / invalid config). Reproducible from (base, head, config) alone.
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
- } catch { throw new Error(`promptwheel check: not inside a git repository (${options.repo || process.cwd()})`); }
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
- throw new Error('promptwheel check: options.config.metrics must be a non-empty array');
1149
+ bad('promptwheel check: options.config.metrics must be a non-empty array');
1131
1150
  } else if (!hasConfigFile(repo)) {
1132
- if (options.auto === false) throw new Error(`promptwheel check: no promptwheel.config.json in ${repo} (pass options.config, or allow auto-detect)`);
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 main() {
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.6",
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": {