promptwheel 0.7.5 → 0.7.6

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/INTEGRATE.md CHANGED
@@ -103,7 +103,9 @@ measurement runs in a throwaway git worktree.
103
103
 
104
104
  Zero dependencies, no build step, the whole engine is one file. `check()` runs the gate in
105
105
  process and returns the Section 2 object. It never prints and never calls `process.exit`; it
106
- throws a plain `Error` on a precondition failure.
106
+ throws a plain `Error` on a precondition failure. Types ship in the package
107
+ (`bin/promptwheel.d.ts`, hand-written, no build step): `import type { Report, CheckOptions, Config } from 'promptwheel'`
108
+ works in TypeScript, and editors complete the options and the report in plain JavaScript.
107
109
 
108
110
  ```js
109
111
  import { check, verdictExitCode, conformsToContract, VERDICTS, CONTRACT_VERSION } from 'promptwheel';
@@ -0,0 +1,306 @@
1
+ // Type declarations for bin/promptwheel.mjs. Hand-written: there is no build step and no
2
+ // TypeScript dependency in this package. Every runtime export of the engine is declared here
3
+ // (test/promptwheel.test.mjs checks the list stays complete). The report shape is the
4
+ // Verified-Outcome Contract's Section 2 verdict object; see INTEGRATE.md for the integration
5
+ // surface and CONTRACT.md for the protocol.
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Section 2 vocabularies
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** The verdict of a check. */
12
+ export type Verdict = 'pass' | 'fail' | 'gamed' | 'inconclusive';
13
+
14
+ /** The status of one metric. A delta inside the measured noise band is `inconclusive`, never a regression. */
15
+ export type MetricStatus = 'improved' | 'regressed' | 'unchanged' | 'inconclusive' | 'unmeasurable';
16
+
17
+ /** The direction that counts as better. `pass` is a 0/1 gate (1 = the command exited 0). */
18
+ export type Direction = 'up' | 'down' | 'pass';
19
+
20
+ /** How much a delta can be trusted. `none` only on an unmeasurable metric; `unverified` is a single read with no noise sample. */
21
+ export type Confidence = 'none' | 'unverified' | 'low' | 'medium' | 'high';
22
+
23
+ /**
24
+ * How a metric's value is read from its command: the last number in stdout (`number`, the
25
+ * default), the count of non-empty lines (`lines`), the exit code as 1/0 (`exit`), or the
26
+ * first capture group of a regex. A command that exits nonzero reads `null` (unmeasurable)
27
+ * unless the metric sets `allowNonzero`; `exit` mode consumes the exit code by design.
28
+ */
29
+ export type ExtractMode = 'number' | 'lines' | 'exit' | { regex: string };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Config (promptwheel.config.json, or the `config` option of check())
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface MetricConfig {
36
+ name: string;
37
+ /** Shell command run in a throwaway worktree at each ref. */
38
+ cmd: string;
39
+ /** Default `'number'`. */
40
+ extract?: ExtractMode;
41
+ /** Default `'up'`. */
42
+ direction?: Direction;
43
+ /** A guarded metric fails the gate on a trusted regression; an unguarded one is tracked only. */
44
+ guard?: boolean;
45
+ /** Read a value even when `cmd` exits nonzero (never needed for `extract: 'exit'`). */
46
+ allowNonzero?: boolean;
47
+ /** Per-command timeout, default 300. */
48
+ timeoutSec?: number;
49
+ /** `false` skips the source-only re-proof for this metric (tripwires whose gain legitimately lives in test files). */
50
+ gamingCheck?: boolean;
51
+ /** Per-metric override of `Config.gamingThreshold`. */
52
+ gamingThreshold?: number;
53
+ }
54
+
55
+ /** A named hard rule: any change touching one of `paths` fails the gate before any metric is read. */
56
+ export interface Mandate {
57
+ name: string;
58
+ /** Globs: a bare name matches that basename at any depth, a pattern with `/` is anchored at the repo root, `**` crosses directories. */
59
+ paths: string[];
60
+ reason?: string;
61
+ }
62
+
63
+ export interface Config {
64
+ /** Path (or paths) to a shared base config whose metrics and mandates this repo inherits; resolved relative to the config file. */
65
+ extends?: string | string[];
66
+ metrics: MetricConfig[];
67
+ mandates?: Mandate[];
68
+ /** Measurement repeats per metric, for the noise band. Default 1. */
69
+ repeat?: number;
70
+ /** Back-compat for `linkDirs`: `false` links nothing. */
71
+ linkNodeModules?: boolean;
72
+ /** Directories outside git to symlink into each worktree (default `['node_modules']`). */
73
+ linkDirs?: string[];
74
+ /** Extra environment for the metric commands; `{wt}` expands to the worktree path. */
75
+ env?: Record<string, string>;
76
+ /** A per-ref build or install, run in the worktree before measuring (a failed setup leaves the metric inert, never a false green). */
77
+ setup?: string;
78
+ /** `false` never appends to `.promptwheel/outcomes.jsonl`. */
79
+ record?: boolean;
80
+ /** Fraction of a gain that must survive the source-only re-run; below it the win is `gamed`. Default 0.5. */
81
+ gamingThreshold?: number;
82
+ /** Extra files that define the gate (a threshold, a budget); a change to one alongside a win is gamed by declaration. Same glob rules as `Mandate.paths`. */
83
+ evaluatorPaths?: string[];
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // check()
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export interface CheckOptions {
91
+ /** Repo path. Default: discovered from `process.cwd()`. */
92
+ repo?: string;
93
+ /** Base ref. Default: the merge-base with `origin/main` (then `origin/master`, `main`, `master`); on the trunk itself, `HEAD~1`. */
94
+ base?: string;
95
+ /** Head ref. Default `HEAD`. */
96
+ head?: string;
97
+ /** Gate uncommitted changes (tracked and untracked) instead of `base..head`. Default `false`. */
98
+ working?: boolean;
99
+ /**
100
+ * A resolved config supplied in process, used as given (an `extends` chain is resolved only
101
+ * when the config is loaded from `promptwheel.config.json`). Omit to load the file, or to
102
+ * auto-detect the test command when there is none.
103
+ */
104
+ config?: Config;
105
+ /** Allow the zero-config auto-detect fallback when there is no config file and no `config` option. Default `true`; `false` throws instead. */
106
+ auto?: boolean;
107
+ /** Measurement repeats for the noise band. Default `config.repeat ?? 1`. */
108
+ repeat?: number;
109
+ /** Run the Section 3 source-only re-proof. Default `true`. */
110
+ detectGaming?: boolean;
111
+ /** A declared override reason (the same as `--allow` or a `Promptwheel-Allow:` commit trailer): a would-be `gamed` reads `pass`, recorded in `report.allowed`. */
112
+ allow?: string;
113
+ /** Append the run to `.promptwheel/outcomes.jsonl`. Default `false`. */
114
+ record?: boolean;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // The report (Section 2 verdict object)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** What `evaluate()` returns for one metric: the before/after medians and the trust call on their delta. */
122
+ export interface Evaluation {
123
+ before: number | null;
124
+ after: number | null;
125
+ /** `null` when either side is unmeasurable. */
126
+ delta: number | null;
127
+ status: MetricStatus;
128
+ /** `false` only for a guarded metric with a trusted regression. */
129
+ ok: boolean;
130
+ confidence: Confidence;
131
+ /** The measured jitter band (max spread across repeats); `null` when unmeasurable. */
132
+ noise: number | null;
133
+ /** A guarded pass/fail metric stuck at 0 at both refs: it protects nothing. */
134
+ inert?: true;
135
+ }
136
+
137
+ export interface MetricResult extends Evaluation {
138
+ name: string;
139
+ direction: Direction;
140
+ guard: boolean;
141
+ /** `true` when the win did not survive the source-only re-run; `null` when the source-only patch did not apply. Absent without gaming detection or when the metric did not improve. */
142
+ gamed?: boolean | null;
143
+ /** The metric's value with only the source edits applied. */
144
+ sourceOnly?: number | null;
145
+ /** The fraction of the gain that survived source-only (0..1). */
146
+ retained?: number | null;
147
+ gamingReason?: string;
148
+ /** Set when a declared override cleared a `gamed` on this metric. */
149
+ allowed?: true;
150
+ }
151
+
152
+ export interface MandateViolation {
153
+ name: string;
154
+ reason: string;
155
+ /** The protected paths this change touched (at most 20). */
156
+ files: string[];
157
+ /** The mandate was deleted in this same change; it stays in force for the change that removes it. */
158
+ removed?: true;
159
+ }
160
+
161
+ export interface GateWeakening {
162
+ name: string;
163
+ change: 'guard-removed' | 'guard-disabled';
164
+ /** What the dropped guard would have read at head under its base definition. */
165
+ status: MetricStatus;
166
+ }
167
+
168
+ export interface EvaluatorChange {
169
+ path: string;
170
+ /** Truncated SHA-256 of the file at each ref; `null` where the file is absent. */
171
+ base: string | null;
172
+ head: string | null;
173
+ }
174
+
175
+ /** The gate-integrity record, present when gaming detection ran. */
176
+ export interface GateRecord {
177
+ /** Truncated SHA-256 of the gate definition (metrics, guards, evaluatorPaths, gamingThreshold, mandates) at each ref; `base` is `null` when there was no config at base. */
178
+ fingerprint: { base: string | null; head: string };
179
+ /** The gate definition differs between base and head. */
180
+ changed?: true;
181
+ /** Guards the change dropped that would have failed at head; non-empty forces `gamed` unless allowed. */
182
+ weakened?: GateWeakening[];
183
+ /** Declared `evaluatorPaths` files whose content changed in this diff. */
184
+ evaluatorChanged?: EvaluatorChange[];
185
+ /** A declared override cleared the weakening. */
186
+ allowed?: true;
187
+ }
188
+
189
+ export interface AllowedRecord {
190
+ reason: string;
191
+ /** The metric names (and `'gate'`) the override cleared. */
192
+ cleared: string[];
193
+ }
194
+
195
+ /** Tamper-evident receipt: re-run `(base, head, config)` and `verdictHash` must match. Additive; never changes the verdict. */
196
+ export interface Provenance {
197
+ tool: 'promptwheel';
198
+ toolVersion: string | null;
199
+ baseSha: string | null;
200
+ headSha: string | null;
201
+ configHash: string;
202
+ verdictHash: string;
203
+ }
204
+
205
+ export interface Report {
206
+ /** Short SHA (or the ref as given when it cannot be resolved). */
207
+ base: string;
208
+ head: string;
209
+ mode: 'refs' | 'working';
210
+ repeat: number;
211
+ verdict: Verdict;
212
+ metrics: MetricResult[];
213
+ /** Present only when a mandate was violated (the verdict is then `fail`). */
214
+ mandates?: MandateViolation[];
215
+ gate?: GateRecord;
216
+ allowed?: AllowedRecord;
217
+ provenance: Provenance;
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Public library API (stable across 0.x minors)
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /** The Contract revision this build implements; bumps only when the verdict schema or the exit codes change. */
225
+ export declare const CONTRACT_VERSION: '1';
226
+ export declare const VERDICTS: readonly ['pass', 'fail', 'gamed', 'inconclusive'];
227
+ export declare const METRIC_STATUSES: readonly ['improved', 'regressed', 'unchanged', 'inconclusive', 'unmeasurable'];
228
+ export declare const DIRECTIONS: readonly ['up', 'down', 'pass'];
229
+
230
+ /** Section 1: `pass` 0, `fail` 1, `gamed` 2, `inconclusive` 3 (any other string maps to 1). The CLI exits with this. */
231
+ export declare function verdictExitCode(verdict: Verdict | string): 0 | 1 | 2 | 3;
232
+
233
+ /**
234
+ * Run the gate in process and return the Section 2 verdict object (identical to `run --json`).
235
+ * Measures only in throwaway git worktrees, never the caller's working tree. Never prints and
236
+ * never calls `process.exit`; throws a plain `Error` on a precondition failure (not a git
237
+ * repository, `auto: false` with no config, a `config` without a non-empty `metrics` array).
238
+ */
239
+ export declare function check(options?: CheckOptions): Report;
240
+
241
+ /**
242
+ * Structurally validate any verdict object against Section 2 (shapes, the fixed vocabularies,
243
+ * and the honest-verdict rules). Zero dependency, so a third-party implementation can self-check
244
+ * the artifact it emits. `errors` is empty exactly when `ok` is `true`.
245
+ */
246
+ export declare function conformsToContract(report: unknown): { ok: boolean; errors: string[] };
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Pure helpers (exported for unit tests and for consumers that want the arithmetic)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /** Read a metric value from a command's stdout and exit code. */
253
+ export declare function extract(stdout: string, code: number, mode?: ExtractMode, allowNonzero?: boolean): number | null;
254
+
255
+ /** Median of the non-null samples; `null` when there are none. */
256
+ export declare function median(samples: ReadonlyArray<number | null | undefined>): number | null;
257
+
258
+ /** `max - min` of the non-null samples; `0` when there are none. */
259
+ export declare function spread(samples: ReadonlyArray<number | null | undefined>): number;
260
+
261
+ /** The credibility core: compare before/after samples and trust the delta only if it clears the observed noise band. */
262
+ export declare function evaluate(
263
+ metric: Pick<MetricConfig, 'direction' | 'guard' | 'extract'>,
264
+ beforeSamples: ReadonlyArray<number | null>,
265
+ afterSamples: ReadonlyArray<number | null>,
266
+ repeat: number,
267
+ ): Evaluation;
268
+
269
+ /** Render a report as the PR-comment markdown the GitHub Action posts. */
270
+ export declare function renderMarkdown(report: Report): string;
271
+
272
+ /** The built-in classifier: is this repo-relative path test / config / grader / golden (not production source)? */
273
+ export declare function isNonSource(path: string): boolean;
274
+
275
+ /** Compile a config's `evaluatorPaths` globs into a matcher over repo-relative paths (always `false` when none are declared). */
276
+ export declare function evaluatorMatcher(config?: { evaluatorPaths?: string[] } | null): (path: string) => boolean;
277
+
278
+ /** Directional improvement of `value` over `before` (positive = better); `null` when either is missing. */
279
+ export declare function gain(metric: { direction?: Direction }, before: number | null, value: number | null): number | null;
280
+
281
+ /** The samples measured with only the source slice of the change applied. */
282
+ export interface SourceOnlyResult {
283
+ samples: ReadonlyArray<number | null>;
284
+ /** `false` when the change touched zero production-source files. */
285
+ hadSourceChange: boolean;
286
+ /** The source-only patch did not apply cleanly, so the judgement is inconclusive. */
287
+ applyFailed?: boolean;
288
+ }
289
+
290
+ export interface GamingJudgement {
291
+ /** `null` when the source-only patch did not apply. */
292
+ gamed: boolean | null;
293
+ sourceOnly: number | null;
294
+ retained: number | null;
295
+ reason: string;
296
+ }
297
+
298
+ /** Did a metric's full-diff win survive on the source edits alone? `gamed` when less than `threshold` (default 0.5) of the gain is retained. */
299
+ export declare function judgeGaming(
300
+ metric: { direction?: Direction; before: number | null; after: number | null },
301
+ sourceOnly: SourceOnlyResult,
302
+ threshold?: number,
303
+ ): GamingJudgement;
304
+
305
+ /** Truncated SHA-256 of the verdict content (everything but `provenance`), the reproducible half of the receipt. */
306
+ export declare function verdictFingerprint(report: Pick<Report, 'verdict'> & Partial<Pick<Report, 'metrics' | 'gate'>>): string;
@@ -884,6 +884,13 @@ function hasEslint(repo) {
884
884
  } catch { return false; }
885
885
  }
886
886
 
887
+ // does git ignore this repo-relative path? `check-ignore -q` exits 0 when it does; 1 when it does
888
+ // not; anything else (not a git repo) counts as not ignored, so init never fails on the check.
889
+ function gitIgnores(repo, relPath) {
890
+ try { execFileSync('git', ['check-ignore', '-q', relPath], { cwd: repo, stdio: 'ignore' }); return true; }
891
+ catch { return false; }
892
+ }
893
+
887
894
  function init(argv) {
888
895
  if (argv.includes('--list')) {
889
896
  console.log('PromptWheel presets (promptwheel init --preset <name>):\n');
@@ -911,8 +918,14 @@ function init(argv) {
911
918
  const src = fileURLToPath(new URL(`../${pk[2]}/`, import.meta.url));
912
919
  const dst = join(repo, '.promptwheel', pk[2]);
913
920
  mkdirSync(dst, { recursive: true });
914
- for (const f of readdirSync(src)) copyFileSync(join(src, f), join(dst, f));
921
+ const files = readdirSync(src);
922
+ for (const f of files) copyFileSync(join(src, f), join(dst, f));
915
923
  console.log(` ✓ installed ${pk[2]} → .promptwheel/${pk[2]} (commit it with the config)`);
924
+ // the gate measures in clean worktrees, so a pack that git ignores (common: the outcome
925
+ // record lives in .promptwheel/ too) is absent at both refs and the metric reads
926
+ // unmeasurable, failing the guard with no hint why. Say so now, while it is one edit.
927
+ if (files.length && gitIgnores(repo, `.promptwheel/${pk[2]}/${files[0]}`))
928
+ console.log(` ⚠ .promptwheel/${pk[2]} is gitignored: the gate measures in clean worktrees, so an ignored pack is absent at both refs and ${m.name} reads unmeasurable (the guard fails). Replace ".promptwheel/" in .gitignore with ".promptwheel/*" and "!.promptwheel/packs/", then commit the pack`);
916
929
  return { ...m, cmd: m.cmd.replaceAll(pk[2] + '/', `.promptwheel/${pk[2]}/`) };
917
930
  });
918
931
  note = presetName;
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "A deterministic, zero-LLM check for your coding loop. It proves every change earned its win by re-running the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook, or import { check } in process.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "promptwheel": "bin/promptwheel.mjs"
8
8
  },
9
+ "types": "./bin/promptwheel.d.ts",
9
10
  "exports": {
10
- ".": "./bin/promptwheel.mjs",
11
+ ".": {
12
+ "types": "./bin/promptwheel.d.ts",
13
+ "default": "./bin/promptwheel.mjs"
14
+ },
11
15
  "./bin/promptwheel.mjs": "./bin/promptwheel.mjs",
12
16
  "./package.json": "./package.json"
13
17
  },
@@ -9,18 +9,28 @@ lower the count by editing the pattern file or a config, only by fixing the code
9
9
 
10
10
  ## Use it
11
11
 
12
- In your `promptwheel.config.json`:
12
+ The pack ships inside the `promptwheel` package, not in your repo, so install it with the
13
+ preset:
13
14
 
14
- ```json
15
- { "extends": ["packs/security/pack.json"] }
15
+ ```bash
16
+ promptwheel init --preset security # or --preset security-antihack, which adds the tripwires
16
17
  ```
17
18
 
18
- Then gate as usual:
19
+ `init` copies the pack into `.promptwheel/packs/security/` and writes a `security_findings`
20
+ metric that runs it from there. Commit the pack with the config: the gate measures in clean
21
+ git worktrees, so a pack that is not in git is absent at both refs and the metric reads
22
+ `unmeasurable` (init warns when `.promptwheel/` is gitignored; change that rule to
23
+ `.promptwheel/*` plus `!.promptwheel/packs/`, since git cannot re-include a file under an
24
+ ignored directory). Then gate as usual:
19
25
 
20
26
  ```bash
21
27
  promptwheel run --working # fails if security_findings increased
22
28
  ```
23
29
 
30
+ `pack.json` is the same metric as a config fragment for `extends`
31
+ (`{ "extends": ["packs/security/pack.json"] }`); its command is relative to a repo that
32
+ holds the pack at `packs/security/`, such as a vendored copy or this repository.
33
+
24
34
  A finding-count that drops because you fixed the code is a `PASS`; a "fix" that
25
35
  only survives with test/config/pattern edits reverted is `GAMED`.
26
36