promptwheel 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Owens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # PromptWheel
2
+
3
+ [![CI](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml/badge.svg)](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Node ≥18](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![deps: zero](https://img.shields.io/badge/deps-zero-blue)
4
+
5
+ **The trustworthy per-turn reward for AI coding loops — proves a turn moved a metric without regressing another.**
6
+
7
+ PromptWheel is the **signal, not the loop driver**: wire it as the verifier inside Claude Code `/loop`, a Ralph `while`-loop, or a Beads pull-loop. Each turn it measures your real repo metrics in throwaway worktrees, refuses to trust a delta inside the noise, and answers one question — *did this turn earn its keep?* — so the loop improves instead of confidently degrading. (In CI it's the **outcome gate for AI code**: the same verdict, as a PR check.)
8
+
9
+ > Same name, new meaning. The "wheel" is the **improvement flywheel**: every turn only counts if it **provably moved a metric without regressing another.** Orchestration (the old "wheel of prompts") is a solved, commoditized problem; the trustworthy reward signal is the open one.
10
+
11
+ AI coding agents (and humans) declare success when a change *compiles and tests pass*. They almost never verify that a change **improved a real measurable thing without regressing another** — and outcome data rots in dashboards (DORA, Swarmia) disconnected from the change that caused it. PromptWheel closes that loop.
12
+
13
+ For any change (`base` → `head`), it measures each configured metric in an **isolated git worktree** before and after, **refuses to trust a delta inside the measurement noise band**, enforces **regression guards**, and emits a structured verdict:
14
+
15
+ ```
16
+ PromptWheel a1b2c3d → e4f5g6h (×5)
17
+
18
+ = tests_pass 1 → 1 (0, unchanged) [guard✓, high]
19
+ ▼ lint_errors 12 → 7 (-5, improved) [guard✓, high]
20
+ ▲ bundle_kb 340 → 352 (+12, regressed) [info, medium]
21
+ ▼ p95_ms 210 → 208 (-2, inconclusive) [info, low]
22
+
23
+ VERDICT: PASS
24
+ ```
25
+
26
+ Exit `0` on pass, `1` on fail (CI-friendly). No build step, zero dependencies, Node 18+.
27
+
28
+ ## Use
29
+
30
+ ```bash
31
+ # 0. write a starter config for your stack (or hand-write promptwheel.config.json)
32
+ npx promptwheel init # detects stack → guarded test metric + lint
33
+ npx promptwheel init --list # presets: tests-pass · lint · bundle-size · llm-eval
34
+
35
+ # measure a change
36
+ npx promptwheel run # base = merge-base with main, head = HEAD
37
+ npx promptwheel run --working # measure UNCOMMITTED changes (incl. newly added files)
38
+ npx promptwheel run --repeat 5 --json # measure 5× to establish a noise band, emit JSON
39
+
40
+ # the loop: run any agent/script, keep the change ONLY if a metric improved
41
+ npx promptwheel improve --attempt "claude -p 'reduce lint errors'"
42
+ # exit 0 = kept a real win · 1 = guarded regression (reverted) · 3 = plateau (reverted) · add --json
43
+
44
+ # what's actually responding in this repo? (aggregates .promptwheel/outcomes.jsonl)
45
+ npx promptwheel insights
46
+ ```
47
+
48
+ It never touches your working tree — every measurement runs in a throwaway worktree. Every gated run appends to `.promptwheel/outcomes.jsonl` (commit it to build the per-repo "what moves what" record; `--no-record` to skip).
49
+
50
+ ## Loop patterns
51
+
52
+ PromptWheel is the gate *inside* a loop you don't have to write:
53
+
54
+ ```bash
55
+ # converge: keep spinning while each turn earns its keep; stop on plateau (3) or regression (1)
56
+ while npx promptwheel improve --attempt "claude -p 'speed up the hot path'"; do :; done
57
+
58
+ # read-only signal inside a driver you control (e.g. Claude Code /loop): gate without committing
59
+ npx promptwheel run --working --json # branch on .verdict / per-metric .status
60
+ ```
61
+
62
+ The exit code is the contract — `0` kept · `1` regression · `3` plateau — so any driver (`/loop`, a Ralph `while`, a Beads pull-loop) converges without parsing anything. PromptWheel never drives the loop; it only says whether the turn counted.
63
+
64
+ ## In Claude Code — plugin
65
+
66
+ Bring the gate into Claude Code as slash commands:
67
+
68
+ ```
69
+ /plugin marketplace add promptwheel-ai/promptwheel
70
+ /plugin install promptwheel@promptwheel-ai
71
+ ```
72
+
73
+ Then `/promptwheel:setup` (write a config) · `/promptwheel:gate` (gate uncommitted changes) · `/promptwheel:improve <cmd>` (keep-if-improved) · `/promptwheel:insights`. The plugin wraps the CLI, so install that too (`npm i -g promptwheel`). See [`plugins/promptwheel/`](plugins/promptwheel/).
74
+
75
+ ## In CI — GitHub Action
76
+
77
+ Drop this in your repo (it posts a verdict comment on every PR and fails the check on a guarded regression beyond noise):
78
+
79
+ ```yaml
80
+ # .github/workflows/promptwheel.yml
81
+ name: PromptWheel
82
+ on: pull_request
83
+ permissions: { contents: read, pull-requests: write }
84
+ jobs:
85
+ outcome-gate:
86
+ runs-on: ubuntu-latest
87
+ steps:
88
+ - uses: actions/checkout@v4
89
+ with: { fetch-depth: 0 }
90
+ - uses: promptwheel-ai/promptwheel@v0
91
+ with: { repeat: '3' }
92
+ ```
93
+
94
+ The Action runs straight from its own checkout — no npm install, no build. See [`action.yml`](action.yml).
95
+
96
+ ## Config — `promptwheel.config.json`
97
+
98
+ ```json
99
+ {
100
+ "repeat": 1,
101
+ "metrics": [
102
+ { "name": "tests_pass", "cmd": "npm test --silent", "extract": "exit", "direction": "pass", "guard": true },
103
+ { "name": "lint_errors", "cmd": "npx eslint . | grep -c error", "extract": "number", "direction": "down", "guard": true },
104
+ { "name": "bundle_kb", "cmd": "du -sk dist | cut -f1", "extract": "number", "direction": "down", "guard": false }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ - **cmd** — any shell command, run inside the worktree.
110
+ - **extract** — reduce its output to a number: `number` (last number, default) · `lines` (count non-empty lines) · `exit` (1 if exit 0 else 0) · `{ "regex": "coverage: (\\d+)" }` (first capture).
111
+ - **direction** — `up` (higher better) · `down` (lower better) · `pass` (boolean 0/1).
112
+ - **guard** — `true` = a *trusted* regression **fails** the gate; `false` = informational.
113
+
114
+ ## Trust model — the point of the whole thing
115
+
116
+ A number that jumps around between runs is worthless as a signal. PromptWheel won't pretend otherwise:
117
+
118
+ - `--repeat N` measures each metric N times at both refs and uses the **median**; the **noise band** is the observed spread.
119
+ - A delta **inside the noise band** is reported `inconclusive` with `low` confidence and **does not fail a guard** (no flaky CI failures).
120
+ - **Confidence:** `high` (deterministic extract, or zero observed noise) · `medium` (delta clears the noise band) · `low` (delta inside noise) · `unverified` (single read — run `--repeat` to earn trust).
121
+
122
+ The accumulated record of **which change-types move which metrics** is the asset: a per-repo reward signal a base tool can't replicate, and the spine that lets an agent loop learn what actually helps.
123
+
124
+ ## Where PromptWheel fits (and where it doesn't)
125
+
126
+ - **vs single-axis CI gates** (Codspeed, Bencher, size-limit, Lighthouse-CI): they own deep statistics on *one* metric; PromptWheel is the **cross-metric gate that composes them** — "did `eval_pass_rate` **and** cost improve without regressing the guards?" in one verdict. Wrap any of them as a metric `cmd` and let `--repeat` handle the noise.
127
+ - **vs loop/agent frameworks** (Ralph, GEPA, reward models): PromptWheel is the **execution-grounded reward they lack** — it runs your real suite with zero deps; it does not drive the loop or do test-time search.
128
+ - **When NOT to use it:** if you only care that tests pass, your base verifier already has you covered. PromptWheel earns its place when you have a **graded numeric metric** beyond pass/fail (eval score, $/run, latency, size) that a change could quietly move.
129
+
130
+ ## Docs
131
+
132
+ - [docs/VISION.md](docs/VISION.md) — why we pivoted from orchestrator to outcome gate, the thesis, the moat, the open-core model.
133
+ - [docs/ROADMAP.md](docs/ROADMAP.md) — the phased plan and the ship-now/stay-thin guardrails.
134
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the engine works: schemas, extract modes, the trust/noise model.
135
+ - [docs/LEARNING.md](docs/LEARNING.md) — the (research-gated) Phase-5 design: ACE-style playbook + UCB work-discovery.
136
+ - [CLAUDE.md](CLAUDE.md) — the constitution for anyone (human or agent) working in this repo.
137
+
138
+ ## Develop
139
+
140
+ ```bash
141
+ npm test # 20 dep-free tests (node:test) — unit + integration, no dependencies
142
+ ```
143
+
144
+ The engine is one importable file; pure helpers are exported for unit tests, the CLI runs only when invoked directly. Add a test with every behavior change.
145
+
146
+ ## Roadmap
147
+
148
+ - [x] before/after worktree measurement + regression guards
149
+ - [x] noise band + confidence (don't trust a delta inside the jitter)
150
+ - [x] `--working` mode — measure uncommitted changes (tracked **and** untracked)
151
+ - [x] persisted reward stream (`.promptwheel/outcomes.jsonl`) — the compounding "what moves what" record
152
+ - [x] GitHub Action / PR-comment wrapper (open-core distribution surface)
153
+ - [x] agent loop: `improve` — propose → gate → keep only if a metric improved
154
+ - [x] loop-consumable `improve`: exit `0` kept / `1` regression / `3` plateau + `--json result`
155
+ - [x] `promptwheel init` + presets — zero-config onboarding
156
+ - [x] `insights` — reward-stream aggregation (Phase-5 seed)
157
+ - [ ] npm publish (the lead magnet) · ACE-style learning + UCB work-discovery (**frozen** — gated on data + ≥1 paid engagement; see [docs/LEARNING.md](docs/LEARNING.md))
158
+
159
+ > Status: v0, runnable, all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ // PromptWheel — the per-turn reward for AI coding loops (a.k.a. the outcome gate for AI code).
3
+ //
4
+ // For any change (base ref → head ref), measure each configured metric in an
5
+ // isolated git worktree BEFORE and AFTER, enforce regression guards, and refuse
6
+ // to trust a delta that sits inside the measurement noise band. Emits a
7
+ // structured {verdict, metric, delta, confidence}. The thing base agents don't do:
8
+ // verify a real number moved — not just "the diff applied / tests passed".
9
+ //
10
+ // Zero deps, zero build. Node 18+.
11
+ // promptwheel run [--base R] [--head R] [--repeat N] [--json]
12
+
13
+ import { execSync, execFileSync } from 'node:child_process';
14
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // helpers
21
+ // ---------------------------------------------------------------------------
22
+ const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
23
+ const median = (xs) => {
24
+ const a = xs.filter((x) => x != null).slice().sort((p, q) => p - q);
25
+ if (!a.length) return null;
26
+ const m = Math.floor(a.length / 2);
27
+ return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
28
+ };
29
+ const spread = (xs) => { const a = xs.filter((x) => x != null); return a.length ? Math.max(...a) - Math.min(...a) : 0; };
30
+
31
+ function loadConfig(repo) {
32
+ const candidates = ['promptwheel.config.json', 'outcome-gate.config.json']; // back-compat alias
33
+ const p = candidates.map((c) => join(repo, c)).find(existsSync);
34
+ if (!p) { console.error('no promptwheel.config.json — run: promptwheel init (writes one for your stack)'); process.exit(2); }
35
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
36
+ if (!Array.isArray(cfg.metrics) || cfg.metrics.length === 0) {
37
+ console.error('config.metrics must be a non-empty array'); process.exit(2);
38
+ }
39
+ return cfg;
40
+ }
41
+
42
+ function resolveBase(repo, base) {
43
+ if (base) return base;
44
+ for (const b of ['origin/main', 'origin/master', 'main', 'master']) {
45
+ try { return git(['merge-base', 'HEAD', b], repo); } catch { /* keep trying */ }
46
+ }
47
+ return git(['rev-parse', 'HEAD~1'], repo);
48
+ }
49
+
50
+ function runMetric(cwd, m) {
51
+ let stdout = '', code = 0;
52
+ try {
53
+ stdout = execSync(m.cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (m.timeoutSec ?? 300) * 1000 });
54
+ } catch (e) { code = e.status ?? 1; stdout = `${e.stdout || ''}${e.stderr || ''}`; }
55
+ return extract(stdout, code, m.extract);
56
+ }
57
+
58
+ // extract modes: 'number' (last number, default) | 'lines' | 'exit' | {regex}
59
+ function extract(stdout, code, mode) {
60
+ mode = mode || 'number';
61
+ if (mode === 'exit') return code === 0 ? 1 : 0;
62
+ if (mode === 'lines') return stdout.split('\n').filter((l) => l.trim()).length;
63
+ if (mode && typeof mode === 'object' && mode.regex) {
64
+ const mm = new RegExp(mode.regex).exec(stdout);
65
+ return mm ? Number(mm[1] ?? mm[0]) : null;
66
+ }
67
+ const nums = stdout.match(/-?\d+(?:\.\d+)?/g);
68
+ return nums ? Number(nums[nums.length - 1]) : null;
69
+ }
70
+
71
+ // measure every metric `repeat` times at a ref, in a throwaway worktree (never touches your tree)
72
+ function measureAt(repo, ref, metrics, linkNodeModules, repeat) {
73
+ const wt = mkdtempSync(join(tmpdir(), 'promptwheel-'));
74
+ git(['worktree', 'add', '--quiet', '--detach', wt, ref], repo);
75
+ try {
76
+ if (linkNodeModules && existsSync(join(repo, 'node_modules')) && !existsSync(join(wt, 'node_modules'))) {
77
+ try { symlinkSync(join(repo, 'node_modules'), join(wt, 'node_modules')); } catch { /* best effort */ }
78
+ }
79
+ const out = {};
80
+ for (const m of metrics) {
81
+ const samples = [];
82
+ for (let i = 0; i < repeat; i++) samples.push(runMetric(wt, m));
83
+ out[m.name] = samples;
84
+ }
85
+ return out;
86
+ } finally {
87
+ try { git(['worktree', 'remove', '--force', wt], repo); } catch { rmSync(wt, { recursive: true, force: true }); }
88
+ }
89
+ }
90
+
91
+ // the credibility core: a delta is only trusted if it clears the observed noise band
92
+ function evaluate(m, beforeS, afterS, repeat) {
93
+ const before = median(beforeS), after = median(afterS);
94
+ if (before == null || after == null) return { before, after, delta: null, status: 'unmeasurable', ok: !m.guard, confidence: 'none', noise: null };
95
+ const dir = m.direction || 'up';
96
+ const delta = +(after - before).toFixed(6);
97
+ const noise = Math.max(spread(beforeS), spread(afterS)); // observed jitter across repeats
98
+ const deterministic = m.extract === 'exit' || m.extract === 'lines';
99
+ const sampledNoise = repeat > 1;
100
+ const withinNoise = sampledNoise && Math.abs(delta) <= noise;
101
+
102
+ let confidence;
103
+ if (deterministic) confidence = 'high';
104
+ else if (!sampledNoise) confidence = 'unverified'; // single read — noise unknown; run --repeat to trust
105
+ else if (noise === 0) confidence = 'high';
106
+ else if (withinNoise) confidence = 'low'; // delta is inside the jitter — not real
107
+ else confidence = 'medium';
108
+
109
+ let improved = false, regressed = false;
110
+ if (dir === 'up') { improved = delta > 0; regressed = delta < 0; }
111
+ else if (dir === 'down') { improved = delta < 0; regressed = delta > 0; }
112
+ else if (dir === 'pass') { improved = after === 1 && before !== 1; regressed = after < before; }
113
+
114
+ let status;
115
+ if (withinNoise && delta !== 0) { status = 'inconclusive'; improved = regressed = false; } // within measured noise → don't call it
116
+ else status = regressed ? 'regressed' : improved ? 'improved' : 'unchanged';
117
+
118
+ // guards fail only on a TRUSTED regression (within-noise regressions are not failures)
119
+ const ok = m.guard ? !regressed : true;
120
+ return { before, after, delta, status, ok, confidence, noise };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // main
125
+ // ---------------------------------------------------------------------------
126
+ // snapshot the working tree (tracked + untracked) into a dangling commit WITHOUT
127
+ // touching the real index or files — so a gate/improve sees a file an agent just added.
128
+ // Returns 'HEAD' when nothing changed.
129
+ function workingSnapshot(repo) {
130
+ const idxDir = mkdtempSync(join(tmpdir(), 'pw-idx-'));
131
+ const env = {
132
+ ...process.env, GIT_INDEX_FILE: join(idxDir, 'index'),
133
+ GIT_AUTHOR_NAME: 'promptwheel', GIT_AUTHOR_EMAIL: 'promptwheel@local',
134
+ GIT_COMMITTER_NAME: 'promptwheel', GIT_COMMITTER_EMAIL: 'promptwheel@local',
135
+ };
136
+ const g = (args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', env }).trim();
137
+ try {
138
+ try { g(['read-tree', 'HEAD']); } catch { /* no HEAD yet (empty repo) */ }
139
+ g(['add', '-A']); // stages tracked + untracked into the TEMP index only
140
+ const tree = g(['write-tree']);
141
+ let headTree = ''; try { headTree = git(['rev-parse', 'HEAD^{tree}'], repo); } catch { /* no HEAD */ }
142
+ if (tree === headTree) return 'HEAD'; // nothing actually changed
143
+ let parent = ''; try { parent = git(['rev-parse', 'HEAD'], repo); } catch { /* no HEAD */ }
144
+ return g(parent
145
+ ? ['commit-tree', tree, '-p', parent, '-m', 'promptwheel working snapshot']
146
+ : ['commit-tree', tree, '-m', 'promptwheel working snapshot']);
147
+ } finally {
148
+ try { rmSync(idxDir, { recursive: true, force: true }); } catch { /* best effort */ }
149
+ }
150
+ }
151
+
152
+ // the shared core: measure a change (base→head) and return the structured report
153
+ function gate(repo, opts) {
154
+ const cfg = loadConfig(repo);
155
+ const repeat = Math.max(1, opts.repeat ?? cfg.repeat ?? 1);
156
+ const linkNM = cfg.linkNodeModules !== false;
157
+
158
+ let base, head;
159
+ if (opts.working) {
160
+ // measure uncommitted changes — tracked AND untracked — via a temp-index snapshot;
161
+ // never touches the real index or working tree. (A loop agent's most common action
162
+ // is to ADD a file; `git stash create` omits untracked, which silently reverted them.)
163
+ base = 'HEAD';
164
+ head = workingSnapshot(repo);
165
+ } else {
166
+ base = resolveBase(repo, opts.base);
167
+ head = opts.head || 'HEAD';
168
+ }
169
+
170
+ const before = measureAt(repo, base, cfg.metrics, linkNM, repeat);
171
+ const after = measureAt(repo, head, cfg.metrics, linkNM, repeat);
172
+ const metrics = cfg.metrics.map((m) => {
173
+ const ev = evaluate(m, before[m.name], after[m.name], repeat);
174
+ return { name: m.name, direction: m.direction || 'up', guard: !!m.guard, ...ev };
175
+ });
176
+ const verdict = metrics.some((m) => m.guard && !m.ok) ? 'fail' : 'pass';
177
+ const report = { base: short(repo, base), head: short(repo, head), repeat, mode: opts.working ? 'working' : 'refs', verdict, metrics };
178
+ if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
179
+ return report;
180
+ }
181
+
182
+ function run(argv) {
183
+ const args = parseArgs(argv);
184
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
185
+ const report = gate(repo, { base: args.base, head: args.head, working: args.working, repeat: args.repeat, noRecord: args.noRecord });
186
+ if (args.json) console.log(JSON.stringify(report, null, 2));
187
+ else if (args.markdown) console.log(renderMarkdown(report));
188
+ else printHuman(report);
189
+ process.exit(report.verdict === 'pass' ? 0 : 1);
190
+ }
191
+
192
+ // the moat: append every gated run to a per-repo outcome record (best-effort, never fails the gate)
193
+ function recordOutcome(repo, report) {
194
+ try {
195
+ const dir = join(repo, '.promptwheel');
196
+ mkdirSync(dir, { recursive: true });
197
+ appendFileSync(join(dir, 'outcomes.jsonl'), JSON.stringify({ ts: new Date().toISOString(), ...report }) + '\n');
198
+ } catch { /* recording must never break the gate */ }
199
+ }
200
+
201
+ // the flywheel: run any agent/script, gate the result, keep the change ONLY if a metric improved
202
+ function improve(argv) {
203
+ const args = parseArgs(argv);
204
+ if (!args.attempt) { console.error('improve requires --attempt "<command that changes the repo>"'); process.exit(2); }
205
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
206
+ const dirty = git(['status', '--porcelain'], repo).split('\n').filter((l) => l.trim() && !l.includes('.promptwheel'));
207
+ if (dirty.length) { console.error('working tree not clean — commit or stash first (improve needs a clean base to revert to)'); process.exit(2); }
208
+
209
+ console.error(`▶ attempt: ${args.attempt}`);
210
+ try { execSync(args.attempt, { cwd: repo, stdio: 'inherit' }); }
211
+ catch (e) { console.error(` (attempt exited ${e.status ?? 1} — gating whatever it changed)`); }
212
+
213
+ const report = gate(repo, { working: true, repeat: args.repeat, noRecord: args.noRecord });
214
+ const noChange = report.metrics.every((m) => m.delta === 0 || m.delta == null);
215
+ const improvedNames = report.metrics.filter((m) => m.status === 'improved').map((m) => m.name);
216
+
217
+ // result + exit code express loop progress so `while improve; do :; done` converges:
218
+ // 0 = kept a real win · 1 = guarded regression (reverted) · 3 = plateau/no-op (reverted)
219
+ let result, exit, note;
220
+ if (report.verdict === 'fail') { result = 'regression'; exit = 1; revert(repo); note = '✗ guarded regression — reverted'; }
221
+ else if (noChange || improvedNames.length === 0) {
222
+ result = 'plateau'; exit = 3; revert(repo);
223
+ note = noChange ? '= no metric moved — reverted' : '= nothing improved beyond noise — reverted';
224
+ } else {
225
+ git(['add', '-A'], repo);
226
+ execFileSync('git', ['commit', '-qm', `promptwheel: ${args.attempt} [improved ${improvedNames.join(', ')}]`], { cwd: repo });
227
+ result = 'kept'; exit = 0;
228
+ note = `✓ kept — committed ${git(['rev-parse', '--short', 'HEAD'], repo)} (improved ${improvedNames.join(', ')})`;
229
+ }
230
+
231
+ // stdout carries the value (JSON report or the human table); the decision line goes to
232
+ // stderr so a loop driver can consume clean stdout.
233
+ if (args.json) console.log(JSON.stringify({ result, ...report }, null, 2));
234
+ else printHuman(report);
235
+ console.error(` ${note}`);
236
+ process.exit(exit);
237
+ }
238
+
239
+ // discard the attempt's changes; keep the .promptwheel outcome record
240
+ function revert(repo) {
241
+ git(['reset', '--hard', 'HEAD'], repo);
242
+ try { git(['clean', '-fd', '-e', '.promptwheel'], repo); } catch { /* best effort */ }
243
+ }
244
+
245
+ // Phase-5 seed: turn the accumulated reward stream into signal. Thin on purpose —
246
+ // this is the substrate a future ACE playbook / UCB work-discovery loop trains on,
247
+ // NOT that loop itself. Just honest aggregation, no model.
248
+ function insights(argv) {
249
+ const args = parseArgs(argv);
250
+ const repo = git(['rev-parse', '--show-toplevel'], process.cwd());
251
+ const f = join(repo, '.promptwheel', 'outcomes.jsonl');
252
+ if (!existsSync(f)) { console.error('no outcome record yet — run the gate a few times first (.promptwheel/outcomes.jsonl)'); process.exit(2); }
253
+ const runs = readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
254
+ const agg = {};
255
+ for (const r of runs) for (const m of (r.metrics || [])) {
256
+ const a = agg[m.name] ??= { runs: 0, improved: 0, regressed: 0, inconclusive: 0, unchanged: 0, net: 0, last: null };
257
+ a.runs++; if (a[m.status] != null) a[m.status]++;
258
+ if (typeof m.delta === 'number') a.net = +(a.net + m.delta).toFixed(6);
259
+ a.last = m.after;
260
+ }
261
+ // "lever score" = how reliably acting on this metric yields a real improvement
262
+ const rows = Object.entries(agg).map(([name, a]) => ({ name, ...a, lever: a.runs ? a.improved / a.runs : 0 }))
263
+ .sort((x, y) => y.lever - x.lever);
264
+ if (args.json) { console.log(JSON.stringify({ runs: runs.length, metrics: rows }, null, 2)); return; }
265
+ console.log(`\nPromptWheel insights — ${runs.length} gated runs recorded\n`);
266
+ console.log(` ${'metric'.padEnd(18)} ${'runs'.padStart(5)} ${'impr'.padStart(5)} ${'regr'.padStart(5)} ${'inconc'.padStart(6)} ${'net Δ'.padStart(9)} lever`);
267
+ for (const r of rows) {
268
+ console.log(` ${r.name.padEnd(18)} ${String(r.runs).padStart(5)} ${String(r.improved).padStart(5)} ${String(r.regressed).padStart(5)} ${String(r.inconclusive).padStart(6)} ${String(r.net).padStart(9)} ${(r.lever * 100).toFixed(0)}%`);
269
+ }
270
+ console.log('\n lever = improved/runs — how reliably this metric actually responds. The');
271
+ console.log(' highest-lever metrics are where an agent loop should spend its attempts.\n');
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // init — write a starter config so a newcomer isn't staring at a blank page
276
+ // ---------------------------------------------------------------------------
277
+ const LINT_CMD = 'npx eslint . -f unix 2>/dev/null | grep -c " error " || true';
278
+ const PRESETS = {
279
+ 'tests-pass': { desc: 'gate: your test suite still passes (command auto-detected)', metrics: null },
280
+ 'lint': { desc: 'track: lint error count does not climb',
281
+ metrics: [{ name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }] },
282
+ 'bundle-size': { desc: 'track: build output size in kB',
283
+ metrics: [{ name: 'bundle_kb', cmd: 'du -sk dist 2>/dev/null | cut -f1 || echo 0', extract: 'number', direction: 'down', guard: false }] },
284
+ 'llm-eval': { desc: 'gate: AI-feature eval pass-rate + est $/run (see examples/reliability-sprint)',
285
+ metrics: [
286
+ { name: 'eval_pass_rate', cmd: 'node eval.mjs', extract: 'number', direction: 'up', guard: true },
287
+ { name: 'cost_per_run_usd', cmd: 'node estimate-cost.mjs', extract: 'number', direction: 'down', guard: true },
288
+ ] },
289
+ };
290
+
291
+ function detectTestCmd(repo) {
292
+ const has = (f) => existsSync(join(repo, f));
293
+ if (has('go.mod')) return 'go test ./...';
294
+ if (has('Cargo.toml')) return 'cargo test';
295
+ if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) return 'pytest -q';
296
+ if (has('package.json')) return 'npm test --silent';
297
+ return 'echo "set your test command in promptwheel.config.json" && false';
298
+ }
299
+
300
+ function init(argv) {
301
+ if (argv.includes('--list')) {
302
+ console.log('PromptWheel presets (promptwheel init --preset <name>):\n');
303
+ for (const [k, p] of Object.entries(PRESETS)) console.log(` ${k.padEnd(13)} ${p.desc}`);
304
+ return;
305
+ }
306
+ const repo = (() => { try { return git(['rev-parse', '--show-toplevel'], process.cwd()); } catch { return process.cwd(); } })();
307
+ const out = join(repo, 'promptwheel.config.json');
308
+ if (existsSync(out) && !argv.includes('--force')) {
309
+ console.error('promptwheel.config.json already exists — pass --force to overwrite'); process.exit(2);
310
+ }
311
+ const pi = argv.indexOf('--preset');
312
+ const presetName = pi >= 0 ? argv[pi + 1] : null;
313
+ let metrics, note;
314
+ if (presetName) {
315
+ const p = PRESETS[presetName];
316
+ if (!p) { console.error(`unknown preset "${presetName}" — try: promptwheel init --list`); process.exit(2); }
317
+ metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
318
+ note = presetName;
319
+ } else {
320
+ // sensible default: tests-pass (guarded) + lint (info — can't fail a newcomer's first run)
321
+ const testCmd = detectTestCmd(repo);
322
+ metrics = [
323
+ { name: 'tests_pass', cmd: testCmd, extract: 'exit', direction: 'pass', guard: true },
324
+ { name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false },
325
+ ];
326
+ note = `tests-pass + lint (detected: ${testCmd})`;
327
+ }
328
+ writeFileSync(out, JSON.stringify({ repeat: 1, metrics }, null, 2) + '\n');
329
+ console.log(`✓ wrote promptwheel.config.json — ${note}`);
330
+ console.log('\n next: promptwheel run --working # gate your uncommitted changes');
331
+ console.log(' promptwheel init --list # other presets (llm-eval, bundle-size, …)');
332
+ }
333
+
334
+ const short = (repo, ref) => { try { return git(['rev-parse', '--short', ref], repo); } catch { return ref; } };
335
+
336
+ function printHuman(r) {
337
+ const arrowFor = (m) => (m.delta == null ? '?' : m.delta > 0 ? '▲' : m.delta < 0 ? '▼' : '=');
338
+ console.log(`\nPromptWheel ${r.base} → ${r.head}${r.repeat > 1 ? ` (×${r.repeat})` : ''}\n`);
339
+ for (const m of r.metrics) {
340
+ const tag = m.guard ? (m.ok ? 'guard✓' : 'GUARD✗') : 'info';
341
+ const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
342
+ console.log(` ${arrowFor(m)} ${m.name.padEnd(18)} ${String(m.before).padStart(8)} → ${String(m.after).padStart(8)} (${d}, ${m.status}) [${tag}, ${m.confidence}]`);
343
+ }
344
+ console.log(`\n VERDICT: ${r.verdict.toUpperCase()}${r.verdict === 'fail' ? ' — a guarded metric regressed (beyond noise)' : ''}\n`);
345
+ }
346
+
347
+ // PR-comment markdown (rendering lives in the tool so the GitHub Action stays thin)
348
+ function renderMarkdown(r) {
349
+ const icon = r.verdict === 'pass' ? '✅' : '❌';
350
+ const sIcon = { improved: '🟢', regressed: '🔴', unchanged: '⚪', inconclusive: '🟡', unmeasurable: '⚫' };
351
+ const rows = r.metrics.map((m) => {
352
+ const d = m.delta == null ? '—' : (m.delta > 0 ? `+${m.delta}` : `${m.delta}`);
353
+ return `| ${m.guard ? '🛡️ ' : ''}${m.name} | ${m.before} | ${m.after} | ${d} | ${sIcon[m.status] || ''} ${m.status} | ${m.confidence} |`;
354
+ }).join('\n');
355
+ return [
356
+ `### ${icon} PromptWheel — outcome gate: **${r.verdict.toUpperCase()}**`,
357
+ '',
358
+ `\`${r.base} → ${r.head}\` · ${r.mode} mode${r.repeat > 1 ? ` · ×${r.repeat}` : ''}`,
359
+ '',
360
+ '| metric | before | after | Δ | status | confidence |',
361
+ '|---|--:|--:|--:|---|---|',
362
+ rows,
363
+ '',
364
+ r.verdict === 'fail' ? '> ❌ A 🛡️ guarded metric regressed beyond the noise band.' : '> ✅ No guarded metric regressed.',
365
+ '',
366
+ '<sub>🛡️ = guard · _prove every change moved a metric_ · [PromptWheel](https://github.com/promptwheel-ai/promptwheel)</sub>',
367
+ ].join('\n');
368
+ }
369
+
370
+ function parseArgs(argv) {
371
+ const a = { json: false };
372
+ for (let i = 0; i < argv.length; i++) {
373
+ if (argv[i] === '--base') a.base = argv[++i];
374
+ else if (argv[i] === '--head') a.head = argv[++i];
375
+ else if (argv[i] === '--repeat') a.repeat = parseInt(argv[++i], 10);
376
+ else if (argv[i] === '--working') a.working = true;
377
+ else if (argv[i] === '--no-record') a.noRecord = true;
378
+ else if (argv[i] === '--attempt') a.attempt = argv[++i];
379
+ else if (argv[i] === '--json') a.json = true;
380
+ else if (argv[i] === '--markdown') a.markdown = true;
381
+ }
382
+ return a;
383
+ }
384
+
385
+ // pure, side-effect-free helpers are exported for unit testing; the CLI below only
386
+ // runs when this file is executed directly (not when imported by the test suite).
387
+ export { extract, evaluate, median, spread, renderMarkdown };
388
+
389
+ function main() {
390
+ const [cmd, ...rest] = process.argv.slice(2);
391
+ if (cmd === 'run') run(rest);
392
+ else if (cmd === 'improve') improve(rest);
393
+ else if (cmd === 'insights') insights(rest);
394
+ else if (cmd === 'init') init(rest);
395
+ else {
396
+ console.log([
397
+ 'PromptWheel — the per-turn reward for AI coding loops. Prove a change moved a metric.',
398
+ '',
399
+ ' promptwheel init [--preset <name> | --list] write a starter config for your stack',
400
+ ' promptwheel run [--base R] [--head R] [--repeat N] [--json|--markdown]',
401
+ ' promptwheel run --working gate uncommitted changes (incl. newly added files)',
402
+ ' promptwheel improve --attempt "<cmd>" run an agent/script; keep only if a metric improved',
403
+ ' exit 0=kept · 1=regression · 3=plateau · add --json',
404
+ ' promptwheel insights which metrics actually respond (loop memory)',
405
+ '',
406
+ 'Loop it: while promptwheel improve --attempt "$AGENT"; do :; done # stops on plateau/regression',
407
+ 'Config: promptwheel.config.json → { metrics:[{ name, cmd, direction, extract?, guard? }] } (or: promptwheel init)',
408
+ ].join('\n'));
409
+ process.exit(cmd ? 2 : 0);
410
+ }
411
+ }
412
+
413
+ // run the CLI only when invoked directly (resolves symlinks so the npm bin still works)
414
+ let isMain = false;
415
+ try { isMain = !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { /* not main */ }
416
+ if (isMain) main();
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "promptwheel",
3
+ "version": "0.0.1",
4
+ "description": "The outcome gate for AI code — prove every change moved a metric. Before/after in an isolated worktree, regression-guarded, noise-aware.",
5
+ "type": "module",
6
+ "bin": {
7
+ "promptwheel": "bin/promptwheel.mjs",
8
+ "pw": "bin/promptwheel.mjs"
9
+ },
10
+ "files": ["bin"],
11
+ "engines": { "node": ">=18" },
12
+ "scripts": {
13
+ "gate": "node bin/promptwheel.mjs run",
14
+ "test": "node --test"
15
+ },
16
+ "license": "MIT",
17
+ "keywords": ["agent", "ci", "regression", "metrics", "verification", "ai-coding", "outcome", "promptwheel"]
18
+ }