promptwheel 0.2.3 → 0.4.0

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
@@ -72,8 +72,15 @@ npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
72
72
 
73
73
  # what's actually responding in this repo? (aggregates .promptwheel/outcomes.jsonl)
74
74
  npx promptwheel insights
75
+
76
+ # EXPERIMENTAL (Phase 5): the earned playbook + where the next attempt should go
77
+ npx promptwheel playbook # decayed, evidence-gated claims distilled from the record
78
+ npx promptwheel suggest # UCB over the lever scores — proven levers vs under-explored arms
79
+ npx promptwheel backfill -n 30 # cold start: seed the record from git history (cohort-tagged, commit types → labels)
75
80
  ```
76
81
 
82
+ **The consequence ledger.** git records *what changed*; PromptWheel records *what the change did* — same trust model (local, deterministic, append-only, no server, no LLM in the verdict). `playbook` and `suggest` are pure re-derivations over that ledger: every rendered line was measured by the gate, decays unless re-earned, and stays hidden below an evidence threshold. No compounding claim is made for them until the A/B acceptance test (`bench/compounding-ab.mjs`) passes on real usage data.
83
+
77
84
  **Footprint:** it never touches your working tree — every measurement runs in a throwaway git worktree **in your system temp dir** (one at a time; `node_modules` is symlinked, not copied), removed when the run finishes. The only thing PromptWheel writes to your repo is the optional `.promptwheel/outcomes.jsonl` record — commit it to build the per-repo "what moves what" history, or `.gitignore` it (`--no-record` to skip entirely). A hard-killed run can't leave clutter behind: the next run **self-heals** any orphaned worktree (stale registry entry + abandoned temp checkout).
78
85
 
79
86
  ## Loop patterns
@@ -187,7 +194,7 @@ The accumulated record of **which change-types move which metrics** is the asset
187
194
  - [docs/VISION.md](docs/VISION.md) — why we pivoted from orchestrator to outcome gate, the thesis, the moat, the open-core model.
188
195
  - [docs/ROADMAP.md](docs/ROADMAP.md) — the phased plan and the ship-now/stay-thin guardrails.
189
196
  - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the engine works: schemas, extract modes, the trust/noise model.
190
- - [docs/LEARNING.md](docs/LEARNING.md) — the (research-gated) Phase-5 design: ACE-style playbook + UCB work-discovery.
197
+ - [docs/LEARNING.md](docs/LEARNING.md) — the (research-gated) Phase-5 design: outcome-curated playbook (Agentic Context Engineering, Stanford 2510.04618) + UCB work-discovery.
191
198
  - [CLAUDE.md](CLAUDE.md) — the constitution for anyone (human or agent) working in this repo.
192
199
 
193
200
  ## Develop
@@ -211,6 +218,6 @@ The engine is one importable file; pure helpers are exported for unit tests, the
211
218
  - [x] `insights` — reward-stream aggregation (Phase-5 seed)
212
219
  - [x] `--detect-gaming` — reward-hack detection: re-prove the win from source edits alone + `antihack` preset
213
220
  - [x] npm publish — `promptwheel@0.1.0` (the lead magnet, shipped 2026-06)
214
- - [ ] ACE-style learning + UCB work-discovery (**frozen** gated on data + ≥1 paid engagement; see [docs/LEARNING.md](docs/LEARNING.md))
221
+ - [x] outcome-curated learning + UCB work-discovery — `playbook` + `suggest` + the compounding A/B harness (**experimental**; compounding *claims* stay gated on real-data proof see [docs/LEARNING.md](docs/LEARNING.md))
215
222
 
216
223
  > Status: **published** (npm `promptwheel`, v0.2.0) — all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
@@ -188,6 +188,18 @@ function changedSourcePaths(repo, base, head) {
188
188
  return { source: all.filter((p) => !isNonSource(p)), nonSource: all.filter(isNonSource) };
189
189
  }
190
190
 
191
+ // coarse change-location fingerprint for the outcome record: top source dirs of the diff
192
+ function subsystemsOf(repo, base, head) {
193
+ try {
194
+ const counts = {};
195
+ for (const p of changedSourcePaths(repo, base, head).source) {
196
+ const d = p.includes('/') ? p.slice(0, p.indexOf('/')) : '(root)';
197
+ counts[d] = (counts[d] || 0) + 1;
198
+ }
199
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d]) => d);
200
+ } catch { return []; }
201
+ }
202
+
191
203
  // measure `metric` in a worktree at `base` with ONLY the source slice of base→head applied.
192
204
  function measureSourceOnly(repo, base, head, metric, linkNM, repeat) {
193
205
  const { source } = changedSourcePaths(repo, base, head);
@@ -349,7 +361,15 @@ function gate(repo, opts) {
349
361
  const failed = metrics.some((m) => m.guard && !m.ok);
350
362
  const gamed = metrics.some((m) => m.gamed === true);
351
363
  const verdict = failed ? 'fail' : gamed ? 'gamed' : 'pass';
352
- const report = { base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs', verdict, metrics };
364
+ const report = {
365
+ base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs',
366
+ // learning-substrate fields (Phase 5): cohort segments reliability by environment,
367
+ // label attributes a change-type, subsystems fingerprint WHERE the change landed.
368
+ cohort: opts.cohort ?? (process.env.CI ? 'ci' : 'local'),
369
+ ...(opts.label ? { label: opts.label } : {}),
370
+ subsystems: subsystemsOf(repo, base, head),
371
+ verdict, metrics,
372
+ };
353
373
  if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
354
374
  return report;
355
375
  }
@@ -357,7 +377,7 @@ function gate(repo, opts) {
357
377
  function run(argv) {
358
378
  const args = parseArgs(argv);
359
379
  const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
360
- const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
380
+ const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming, label: args.label });
361
381
  if (args.json) console.log(JSON.stringify(report, null, 2));
362
382
  else if (args.markdown) console.log(renderMarkdown(report));
363
383
  else printHuman(report);
@@ -385,7 +405,7 @@ function improve(argv) {
385
405
  try { execSync(args.attempt, { cwd: repo, stdio: 'inherit' }); }
386
406
  catch (e) { console.error(` (attempt exited ${e.status ?? 1} — gating whatever it changed)`); }
387
407
 
388
- const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming });
408
+ const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord, detectGaming: args.detectGaming, label: args.label ?? args.attempt });
389
409
  const noChange = report.metrics.every((m) => m.delta === 0 || m.delta == null);
390
410
  const improvedNames = report.metrics.filter((m) => m.status === 'improved').map((m) => m.name);
391
411
 
@@ -419,7 +439,7 @@ function revert(repo) {
419
439
  }
420
440
 
421
441
  // Phase-5 seed: turn the accumulated reward stream into signal. Thin on purpose —
422
- // this is the substrate a future ACE playbook / UCB work-discovery loop trains on,
442
+ // this is the substrate a future outcome-curated playbook / UCB work-discovery loop trains on,
423
443
  // NOT that loop itself. Just honest aggregation, no model.
424
444
  function insights(argv) {
425
445
  const args = parseArgs(argv);
@@ -447,6 +467,149 @@ function insights(argv) {
447
467
  console.log(' highest-lever metrics are where an agent loop should spend its attempts.\n');
448
468
  }
449
469
 
470
+ // ---------------------------------------------------------------------------
471
+ // Phase 5 — outcome-curated playbook + UCB work-discovery.
472
+ // Unfrozen 2026-07-02 by explicit founder decision overriding D7 (see docs/LEARNING.md).
473
+ // Design constraints that survive the unfreeze:
474
+ // - PURE VIEW: the append-only ledger (.promptwheel/outcomes.jsonl) is the only store;
475
+ // the playbook is re-derived on every read (decay at read time — no curator state to rot).
476
+ // - EVIDENCE-GATED: a key renders a claim only past MIN_MOVED weighted observations;
477
+ // everything below that is counted, not asserted.
478
+ // - CLAIM-GATED: no public compounding claim until bench/compounding-ab.mjs passes on
479
+ // real usage data. The gate on the CLAIM outlived the gate on the CODE.
480
+ // ---------------------------------------------------------------------------
481
+ const HALF_LIFE = 20; // runs — an entry's weight halves every HALF_LIFE runs unless re-earned
482
+ const MIN_MOVED = 3; // weighted moved-observations before a key earns a rendered claim
483
+ const EXPLORE_C = 1.0; // UCB exploration constant
484
+
485
+ function readLedger(repo) {
486
+ const f = join(repo, '.promptwheel', 'outcomes.jsonl');
487
+ if (!existsSync(f)) return [];
488
+ return readFileSync(f, 'utf8').split('\n').filter(Boolean)
489
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
490
+ }
491
+
492
+ // fold the ledger into decayed per-key stats. keys: `metric` · `metric @ subsystem` · `metric # label`
493
+ function foldOutcomes(runs) {
494
+ const keys = new Map();
495
+ const N = runs.length;
496
+ runs.forEach((r, idx) => {
497
+ const w = Math.pow(0.5, (N - 1 - idx) / HALF_LIFE); // idx N-1 = newest → weight 1
498
+ for (const m of (r.metrics || [])) {
499
+ const tags = [m.name];
500
+ for (const s of (r.subsystems || [])) tags.push(`${m.name} @ ${s}`);
501
+ if (r.label) tags.push(`${m.name} # ${r.label}`);
502
+ for (const key of tags) {
503
+ const k = keys.get(key) ?? { key, metric: m.name, w: 0, n: 0, imp: 0, reg: 0, moved: 0, effects: [], cohorts: {}, last: null };
504
+ k.n += 1; k.w += w;
505
+ const coh = (k.cohorts[r.cohort || 'unknown'] ??= { imp: 0, reg: 0 });
506
+ if (m.status === 'improved') {
507
+ k.imp += w; k.moved += w; coh.imp += 1;
508
+ if (typeof m.delta === 'number' && m.delta !== 0) k.effects.push(Math.abs(m.delta));
509
+ } else if (m.status === 'regressed') { k.reg += w; k.moved += w; coh.reg += 1; }
510
+ k.last = r.ts || k.last;
511
+ keys.set(key, k);
512
+ }
513
+ }
514
+ });
515
+ return [...keys.values()];
516
+ }
517
+
518
+ const betaMean = (imp, reg) => (1 + imp) / (2 + imp + reg); // Beta(1,1)-smoothed helpfulness
519
+
520
+ // composite lever = smoothed helpfulness × responsiveness; UCB adds an exploration bonus
521
+ function scoreKey(k, totalW) {
522
+ const p = betaMean(k.imp, k.reg);
523
+ const move = k.w ? k.moved / k.w : 0;
524
+ const lever = p * move;
525
+ const ucb = lever + EXPLORE_C * Math.sqrt(Math.log(Math.max(Math.E, totalW)) / Math.max(1e-6, k.w));
526
+ return { p: +p.toFixed(3), move: +move.toFixed(3), lever: +lever.toFixed(3), ucb: +ucb.toFixed(3), effect: +(median(k.effects) ?? 0).toFixed(6) };
527
+ }
528
+
529
+ // cohorts that disagree in sign are flagged, never averaged into a lie
530
+ function cohortNote(k) {
531
+ const cs = Object.entries(k.cohorts).filter(([, c]) => c.imp + c.reg > 0);
532
+ if (cs.length < 2) return '';
533
+ const signs = new Set(cs.map(([, c]) => Math.sign(c.imp - c.reg)));
534
+ return signs.size > 1 ? ' · ⚠ cohort-dependent (ci vs local disagree)' : '';
535
+ }
536
+
537
+ function loadEntries(repo) {
538
+ const runs = readLedger(repo);
539
+ if (!runs.length) { console.error('no outcome record yet — run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
540
+ const keys = foldOutcomes(runs);
541
+ const totalW = keys.filter((k) => k.key === k.metric).reduce((s, k) => s + k.w, 0);
542
+ const entries = keys.map((k) => ({ ...k, ...scoreKey(k, totalW), sufficient: k.moved >= MIN_MOVED }))
543
+ .sort((a, b) => b.lever - a.lever || b.w - a.w);
544
+ return { runs, entries };
545
+ }
546
+
547
+ // the distilled, execution-earned context an agent (or CLAUDE.md) can consume
548
+ function playbook(argv) {
549
+ const args = parseArgs(argv);
550
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
551
+ const { runs, entries } = loadEntries(repo);
552
+ if (args.json) { console.log(JSON.stringify({ runs: runs.length, halfLife: HALF_LIFE, minMoved: MIN_MOVED, entries }, null, 2)); return; }
553
+ const suff = entries.filter((e) => e.sufficient);
554
+ const pct = (x) => `${Math.round(x * 100)}%`;
555
+ const out = [];
556
+ out.push(`## Earned playbook — ${runs.length} gated runs, decay half-life ${HALF_LIFE} runs`);
557
+ out.push('*(every line below was measured by the gate, not asserted; entries decay unless re-earned)*', '');
558
+ if (!suff.length) {
559
+ out.push(`_Nothing has earned a claim yet — a key needs ≥${MIN_MOVED} weighted moved-observations. Keep gating; ${entries.length} keys are accumulating._`);
560
+ } else {
561
+ for (const e of suff.slice(0, 20)) {
562
+ out.push(`- **${e.key}** — helpful ${pct(e.p)} when it moves (${e.imp.toFixed(1)}✓/${e.reg.toFixed(1)}✗ weighted), responds in ${pct(e.move)} of runs, median effect ${e.effect}${cohortNote(e)}`);
563
+ }
564
+ const hidden = entries.length - suff.length;
565
+ if (hidden > 0) out.push('', `_${hidden} more keys below the evidence threshold — counted, not asserted._`);
566
+ }
567
+ console.log(out.join('\n'));
568
+ }
569
+
570
+ // UCB work-discovery: where should the loop spend its NEXT attempt? Proposes measured
571
+ // targets (metric/subsystem arms) — never code advice; the measurement stays the message.
572
+ function suggest(argv) {
573
+ const args = parseArgs(argv);
574
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
575
+ const { runs, entries } = loadEntries(repo);
576
+ const arms = entries.filter((e) => e.key === e.metric || e.key.includes(' @ ')).sort((a, b) => b.ucb - a.ucb);
577
+ if (args.json) { console.log(JSON.stringify({ runs: runs.length, thin: runs.length < 10, arms: arms.slice(0, 10) }, null, 2)); return; }
578
+ console.log(`\nPromptWheel suggest — UCB over ${runs.length} gated runs${runs.length < 10 ? ' (thin record: exploration dominates — treat as a coin with opinions)' : ''}\n`);
579
+ for (const a of arms.slice(0, 5)) {
580
+ console.log(` ${a.ucb.toFixed(3)} ${a.key.padEnd(28)} lever ${a.lever.toFixed(3)} (helpful ${a.p}, responds ${a.move}) · evidence ${a.moved.toFixed(1)} moved / ${a.n} runs${a.sufficient ? '' : ' · below claim threshold'}`);
581
+ }
582
+ console.log('\n score = lever + exploration bonus — high scores are either proven levers or under-explored arms.\n');
583
+ }
584
+
585
+ // backfill — seed the ledger from git history: replay past commits through the CURRENT
586
+ // metrics (LEARNING.md harvest path 1). Deterministic, no LLM. Rows are cohort-tagged
587
+ // 'backfill' — historical human commits are NOT live agent-loop evidence; the cohort
588
+ // machinery segments them and flags disagreement rather than averaging it away. The
589
+ // conventional-commit type (fix/feat/refactor/…) becomes the change-type label for free.
590
+ function backfill(argv) {
591
+ const args = parseArgs(argv);
592
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
593
+ const listArgs = ['rev-list', '--no-merges'];
594
+ if (args.since) listArgs.push(`${args.since}..HEAD`); else listArgs.push('-n', String(args.n ?? 30), 'HEAD');
595
+ const commits = git(listArgs, repo).split('\n').filter(Boolean).reverse(); // oldest first → ledger stays chronological, decay stays honest
596
+ if (!commits.length) { console.error('no commits to backfill'); process.exit(2); }
597
+ const seen = new Set(readLedger(repo).filter((r) => r.cohort === 'backfill').map((r) => r.head));
598
+ let done = 0, skipped = 0;
599
+ console.log(`\nbackfilling ${commits.length} commits through the current metrics (gaming detection ${args.dgExplicit && args.detectGaming ? 'ON' : 'off — pass --detect-gaming to audit history too'})\n`);
600
+ for (const c of commits) {
601
+ try { git(['rev-parse', `${c}~1`], repo); } catch { skipped++; continue; } // root commit
602
+ if (seen.has(short(repo, c))) { skipped++; continue; } // already recorded
603
+ const subj = git(['log', '-1', '--format=%s', c], repo);
604
+ const type = /^(feat|fix|docs|test|chore|refactor|perf|ci|build|style)\b/i.exec(subj)?.[1]?.toLowerCase();
605
+ const report = gate(repo, { base: `${c}~1`, head: c, repeat: args.repeat, detectGaming: args.dgExplicit ? args.detectGaming : false, label: type, cohort: 'backfill' });
606
+ console.log(` ${report.head} ${report.verdict.toUpperCase().padEnd(6)} ${type ? `#${type}`.padEnd(10) : ''.padEnd(10)} ${subj.slice(0, 56)}`);
607
+ done++;
608
+ }
609
+ console.log(`\n backfilled ${done}${skipped ? ` (skipped ${skipped}: root or already recorded)` : ''} — old commits that no longer build record as unmeasurable, never faked.`);
610
+ console.log(' next: promptwheel playbook · promptwheel suggest\n');
611
+ }
612
+
450
613
  // ---------------------------------------------------------------------------
451
614
  // init — write a starter config so a newcomer isn't staring at a blank page
452
615
  // ---------------------------------------------------------------------------
@@ -610,24 +773,30 @@ function parseArgs(argv) {
610
773
  else if (argv[i] === '--repeat') a.repeat = parseInt(argv[++i], 10);
611
774
  else if (argv[i] === '--working') a.working = true;
612
775
  else if (argv[i] === '--no-record') a.noRecord = true;
613
- else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') a.detectGaming = true;
614
- else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') a.detectGaming = false;
776
+ else if (argv[i] === '--detect-gaming' || argv[i] === '--antihack') { a.detectGaming = true; a.dgExplicit = true; }
777
+ else if (argv[i] === '--no-detect-gaming' || argv[i] === '--no-antihack') { a.detectGaming = false; a.dgExplicit = true; }
778
+ else if (argv[i] === '--since') a.since = argv[++i];
779
+ else if (argv[i] === '-n' || argv[i] === '--limit') a.n = parseInt(argv[++i], 10);
615
780
  else if (argv[i] === '--attempt') a.attempt = argv[++i];
781
+ else if (argv[i] === '--label') a.label = argv[++i];
616
782
  else if (argv[i] === '--json') a.json = true;
617
- else if (argv[i] === '--markdown') a.markdown = true;
783
+ else if (argv[i] === '--markdown' || argv[i] === '--md') a.markdown = true;
618
784
  }
619
785
  return a;
620
786
  }
621
787
 
622
788
  // pure, side-effect-free helpers are exported for unit testing; the CLI below only
623
789
  // runs when this file is executed directly (not when imported by the test suite).
624
- export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming };
790
+ export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming, foldOutcomes, betaMean, scoreKey };
625
791
 
626
792
  function main() {
627
793
  const [cmd, ...rest] = process.argv.slice(2);
628
794
  if (cmd === 'run') run(rest);
629
795
  else if (cmd === 'improve') improve(rest);
630
796
  else if (cmd === 'insights') insights(rest);
797
+ else if (cmd === 'playbook') playbook(rest);
798
+ else if (cmd === 'suggest') suggest(rest);
799
+ else if (cmd === 'backfill') backfill(rest);
631
800
  else if (cmd === 'init') init(rest);
632
801
  else if (cmd === 'guards') guards(rest);
633
802
  else {
@@ -641,7 +810,10 @@ function main() {
641
810
  ' promptwheel run --working gate uncommitted changes (incl. newly added files)',
642
811
  ' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
643
812
  ' exit 0=kept · 1=regression · 3=plateau · add --json',
644
- ' promptwheel insights which metrics actually respond (loop memory)',
813
+ ' promptwheel insights which metrics actually respond (raw counts)',
814
+ ' promptwheel playbook [--json] the earned playbook: decayed, evidence-gated claims distilled from the record',
815
+ ' promptwheel suggest [--json] UCB work-discovery: where the next attempt should go (experimental)',
816
+ ' promptwheel backfill [-n N | --since <ref>] seed the ledger from git history (cohort-tagged; commit types become labels)',
645
817
  ' promptwheel guards show the effective guardrails (incl. inherited) + flag record',
646
818
  '',
647
819
  'Loop it: while promptwheel improve --attempt "$AGENT"; do :; done # stops on plateau/regression',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Catch your agent cheating — a deterministic, zero-LLM CLI that flags when an AI coding agent gamed its own metric (re-proves the win from the agent's source edits alone). Built on an outcome gate; runs in CI or a Claude Code hook.",
5
5
  "type": "module",
6
6
  "bin": {