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 +21 -0
- package/README.md +159 -0
- package/bin/promptwheel.mjs +416 -0
- package/package.json +18 -0
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
|
+
[](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml) [](LICENSE)  
|
|
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
|
+
}
|