crewhaus 0.1.8 → 0.2.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/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +173 -0
- package/dist/dataset-mine.js +404 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11664 -963
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/route.d.ts +19 -0
- package/dist/route.js +90 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +174 -0
- package/dist/security-digest.js +656 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +300 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +90 -65
package/dist/flywheel.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 45 — `crewhaus flywheel init|run`: the nightly self-improvement loop
|
|
3
|
+
* as a product one-liner. Productizes the live-verified EP01 demo script
|
|
4
|
+
* (compile gate → baseline eval → optimize → post-patch compile → after
|
|
5
|
+
* eval → diff → acceptance gate → write-back) and its GitHub Actions
|
|
6
|
+
* schedule, keeping the demo's invariants:
|
|
7
|
+
*
|
|
8
|
+
* - a PR opened by the scaffolded workflow is NEVER auto-merged — a
|
|
9
|
+
* human is always the last gate;
|
|
10
|
+
* - the optimizer only ever touches `agent.instructions` (spec-patch's
|
|
11
|
+
* OPTIMIZABLE_PATHS enforces this — `permissions:` stays exactly as a
|
|
12
|
+
* human last reviewed it);
|
|
13
|
+
* - the flywheel refuses to run over uncommitted spec changes
|
|
14
|
+
* (`--allow-dirty` opts out);
|
|
15
|
+
* - ACCEPT-THEN-WRITE: `optimize` runs WITHOUT write-back and the patch
|
|
16
|
+
* is applied to the source spec only after the acceptance gate passes
|
|
17
|
+
* (pass_rate strictly up AND zero per-sample regressions, via the same
|
|
18
|
+
* `gateRuns` the eval run-history uses). A rejected patch never
|
|
19
|
+
* touches disk, so there is no write→reject→revert window at all.
|
|
20
|
+
*
|
|
21
|
+
* Kept in a side-effect-free module (the CLI entry file runs an argv
|
|
22
|
+
* switch on import) mirroring `eval-history.ts` / `datasets.ts`: the loop
|
|
23
|
+
* itself takes injected step hooks so accept/reject/dry-run logic is
|
|
24
|
+
* unit-testable with synthetic fitness — no credentials needed.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
import { gateRuns } from "./eval-history";
|
|
29
|
+
/** Thrown on invalid knobs/defaults and scaffold refusals. The CLI entry
|
|
30
|
+
* file catches it and routes the message through `die()`; tests assert on
|
|
31
|
+
* `.message` without the process exiting (mirrors DatasetRefError). */
|
|
32
|
+
export class FlywheelConfigError extends Error {
|
|
33
|
+
name = "FlywheelConfigError";
|
|
34
|
+
}
|
|
35
|
+
// -------- knobs (flag > FLYWHEEL_* env > default) --------
|
|
36
|
+
/**
|
|
37
|
+
* The demo's env knob names, kept verbatim for continuity — a repo that ran
|
|
38
|
+
* the EP01 `nightly-flywheel.sh` can point its existing workflow env at
|
|
39
|
+
* `crewhaus flywheel run` without renaming a secret or a variable.
|
|
40
|
+
*/
|
|
41
|
+
export const FLYWHEEL_ENV_KNOBS = {
|
|
42
|
+
budgetUsd: "FLYWHEEL_BUDGET_USD",
|
|
43
|
+
iterations: "FLYWHEEL_ITERATIONS",
|
|
44
|
+
seed: "FLYWHEEL_SEED",
|
|
45
|
+
concurrency: "FLYWHEEL_CONCURRENCY",
|
|
46
|
+
};
|
|
47
|
+
/** Demo defaults: $2 cap, 3 iterations, seed 1 (reproducible PRs),
|
|
48
|
+
* concurrency 1 (rate-limit-tier safe). */
|
|
49
|
+
export const FLYWHEEL_DEFAULT_KNOBS = {
|
|
50
|
+
budgetUsd: 2,
|
|
51
|
+
iterations: 3,
|
|
52
|
+
seed: 1,
|
|
53
|
+
concurrency: 1,
|
|
54
|
+
};
|
|
55
|
+
function parsePositiveFloat(raw, source) {
|
|
56
|
+
// Number() instead of parseFloat() so trailing garbage ("2abc") is
|
|
57
|
+
// rejected instead of silently truncated, plus an explicit finite check
|
|
58
|
+
// so "Infinity" (a valid Number) can never become a budget.
|
|
59
|
+
const n = Number(raw.trim());
|
|
60
|
+
if (raw.trim() === "" || !Number.isFinite(n) || n <= 0) {
|
|
61
|
+
throw new FlywheelConfigError(`invalid ${source} "${raw}" — must be a positive finite number`);
|
|
62
|
+
}
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
function parsePositiveInt(raw, source) {
|
|
66
|
+
const n = Number.parseInt(raw, 10);
|
|
67
|
+
if (Number.isNaN(n) || n < 1 || String(n) !== raw.trim()) {
|
|
68
|
+
throw new FlywheelConfigError(`invalid ${source} "${raw}" — must be a positive integer`);
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
function parseInteger(raw, source) {
|
|
73
|
+
const n = Number.parseInt(raw, 10);
|
|
74
|
+
if (Number.isNaN(n)) {
|
|
75
|
+
throw new FlywheelConfigError(`invalid ${source} "${raw}" — must be an integer`);
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
const KNOB_SPECS = [
|
|
80
|
+
{
|
|
81
|
+
key: "budgetUsd",
|
|
82
|
+
flag: "budget-usd",
|
|
83
|
+
env: FLYWHEEL_ENV_KNOBS.budgetUsd,
|
|
84
|
+
parse: parsePositiveFloat,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: "iterations",
|
|
88
|
+
flag: "iterations",
|
|
89
|
+
env: FLYWHEEL_ENV_KNOBS.iterations,
|
|
90
|
+
parse: parsePositiveInt,
|
|
91
|
+
},
|
|
92
|
+
{ key: "seed", flag: "seed", env: FLYWHEEL_ENV_KNOBS.seed, parse: parseInteger },
|
|
93
|
+
{
|
|
94
|
+
key: "concurrency",
|
|
95
|
+
flag: "concurrency",
|
|
96
|
+
env: FLYWHEEL_ENV_KNOBS.concurrency,
|
|
97
|
+
parse: parsePositiveInt,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the run knobs with flag > FLYWHEEL_* env > default precedence.
|
|
102
|
+
* The env layer exists so the scaffolded workflow (and the EP01 demo's)
|
|
103
|
+
* can steer a run purely through repository variables/dispatch inputs.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveFlywheelKnobs(opts) {
|
|
106
|
+
const knobs = { ...FLYWHEEL_DEFAULT_KNOBS };
|
|
107
|
+
for (const spec of KNOB_SPECS) {
|
|
108
|
+
const flagValue = opts.flags[spec.flag];
|
|
109
|
+
if (typeof flagValue === "string") {
|
|
110
|
+
knobs[spec.key] = spec.parse(flagValue, `--${spec.flag}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const envValue = opts.env[spec.env];
|
|
114
|
+
if (envValue !== undefined && envValue !== "") {
|
|
115
|
+
knobs[spec.key] = spec.parse(envValue, spec.env);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return knobs;
|
|
119
|
+
}
|
|
120
|
+
/** The standalone-harness convention paths the flywheel defaults to. */
|
|
121
|
+
export const CONVENTIONAL_DATASET = join("eval", "dataset.jsonl");
|
|
122
|
+
export const CONVENTIONAL_GRADERS = join("eval", "graders.yaml");
|
|
123
|
+
/**
|
|
124
|
+
* Resolve where the flywheel's dataset + graders come from when the flags
|
|
125
|
+
* are omitted: the standalone-harness convention (`eval/dataset.jsonl` /
|
|
126
|
+
* `eval/graders.yaml` beside the SPEC — resolved from the spec's directory,
|
|
127
|
+
* matching the dirty-check's spec-dir behavior, so `flywheel run
|
|
128
|
+
* path/to/crewhaus.yaml` from a sibling dir still finds the harness's own
|
|
129
|
+
* files; flag paths stay cwd-relative), and — when the spec declares a
|
|
130
|
+
* `feedback:` block and ratings have been distilled into the registry —
|
|
131
|
+
* the `registry:<spec>-ratings` dataset the autoDistill flywheel feeds.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveFlywheelData(opts) {
|
|
134
|
+
let dataset;
|
|
135
|
+
let datasetSource;
|
|
136
|
+
if (opts.datasetFlag !== undefined) {
|
|
137
|
+
dataset = opts.datasetFlag;
|
|
138
|
+
datasetSource = "flag";
|
|
139
|
+
}
|
|
140
|
+
else if (opts.hasConventionalDataset) {
|
|
141
|
+
dataset = join(opts.specDir, CONVENTIONAL_DATASET);
|
|
142
|
+
datasetSource = "convention";
|
|
143
|
+
}
|
|
144
|
+
else if (opts.ratingsRegistered) {
|
|
145
|
+
dataset = `registry:${opts.specName}-ratings`;
|
|
146
|
+
datasetSource = "ratings-registry";
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
throw new FlywheelConfigError(`no dataset — pass --dataset <file|registry:name>, create ${CONVENTIONAL_DATASET}, ` +
|
|
150
|
+
`or distill user ratings first (\`crewhaus distill --all-sessions --register ${opts.specName}-ratings\`)`);
|
|
151
|
+
}
|
|
152
|
+
let graders;
|
|
153
|
+
let gradersSource;
|
|
154
|
+
if (opts.gradersFlag !== undefined) {
|
|
155
|
+
graders = opts.gradersFlag;
|
|
156
|
+
gradersSource = "flag";
|
|
157
|
+
}
|
|
158
|
+
else if (opts.hasConventionalGraders) {
|
|
159
|
+
graders = join(opts.specDir, CONVENTIONAL_GRADERS);
|
|
160
|
+
gradersSource = "convention";
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
throw new FlywheelConfigError(`no graders — pass --graders <graders.yaml>, create ${CONVENTIONAL_GRADERS}, or synthesize one from ratings (\`crewhaus distill --all-sessions --graders-out eval/graders.yaml\`)`);
|
|
164
|
+
}
|
|
165
|
+
return { dataset, graders, datasetSource, gradersSource };
|
|
166
|
+
}
|
|
167
|
+
// -------- git cleanliness (invariant: never run over uncommitted spec edits) --------
|
|
168
|
+
/** True when `git status --porcelain -- <spec>` reported any change. */
|
|
169
|
+
export function specIsDirty(porcelain) {
|
|
170
|
+
return porcelain.trim() !== "";
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The flywheel's acceptance bar: BOTH conditions must hold —
|
|
174
|
+
* (a) the strict run-history gate passes (`gateRuns`: any pass-rate drop
|
|
175
|
+
* or sample-level pass→fail flip fails, even when recoveries cancel
|
|
176
|
+
* it out in the aggregates), and
|
|
177
|
+
* (b) pass_rate STRICTLY improved (a flat run passes the gate but is not
|
|
178
|
+
* worth a write-back/PR — taste over averages, per the demo).
|
|
179
|
+
*/
|
|
180
|
+
export function evaluateFlywheelAcceptance(before, after) {
|
|
181
|
+
const passRateBefore = before.aggregates.passRate;
|
|
182
|
+
const passRateAfter = after.aggregates.passRate;
|
|
183
|
+
const verdict = gateRuns(before, after);
|
|
184
|
+
const base = {
|
|
185
|
+
passRateBefore,
|
|
186
|
+
passRateAfter,
|
|
187
|
+
regressions: verdict.report.regressions.length,
|
|
188
|
+
recoveries: verdict.report.recoveries.length,
|
|
189
|
+
};
|
|
190
|
+
// Fail closed on incomparable pass rates: a NaN/undefined rate (e.g. a
|
|
191
|
+
// 0-sample run) satisfies neither `<=` nor `>`, so a strictly-up check
|
|
192
|
+
// written as `after <= before → reject` would ACCEPT it. Reject
|
|
193
|
+
// explicitly before any comparison runs.
|
|
194
|
+
if (!Number.isFinite(passRateBefore) || !Number.isFinite(passRateAfter)) {
|
|
195
|
+
return {
|
|
196
|
+
accepted: false,
|
|
197
|
+
reason: `pass_rate not comparable (before=${String(passRateBefore)}, ` +
|
|
198
|
+
`after=${String(passRateAfter)}) — rejecting fail-closed`,
|
|
199
|
+
...base,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (verdict.verdict === "fail") {
|
|
203
|
+
return { accepted: false, reason: verdict.reason ?? "regression gate failed", ...base };
|
|
204
|
+
}
|
|
205
|
+
// Fail-closed form: only a provable strict improvement accepts — any
|
|
206
|
+
// non-ordered pair (including NaN, belt to the check above) rejects.
|
|
207
|
+
if (!(passRateAfter > passRateBefore)) {
|
|
208
|
+
return {
|
|
209
|
+
accepted: false,
|
|
210
|
+
reason: `pass_rate did not strictly improve (${formatPassRate(passRateBefore)} → ` +
|
|
211
|
+
`${formatPassRate(passRateAfter)})`,
|
|
212
|
+
...base,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
accepted: true,
|
|
217
|
+
reason: `pass_rate ${formatPassRate(passRateBefore)} → ${formatPassRate(passRateAfter)} with zero regressions`,
|
|
218
|
+
...base,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export function formatPassRate(rate) {
|
|
222
|
+
return `${(rate * 100).toFixed(1)}%`;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The complete loop with accept-then-write semantics. The source spec is
|
|
226
|
+
* only ever touched by `hooks.applyAccepted`, which runs exactly when the
|
|
227
|
+
* acceptance gate passed AND `dryRun` is false — every other path leaves
|
|
228
|
+
* the working tree byte-identical, so there is nothing to revert. A
|
|
229
|
+
* pre-optimize compile failure propagates (nothing was spent yet); a
|
|
230
|
+
* post-optimize compile failure maps to `"patch-compile-failed"` (the
|
|
231
|
+
* optimizer produced YAML the compiler rejects — an error, but the source
|
|
232
|
+
* is untouched).
|
|
233
|
+
*/
|
|
234
|
+
export async function runFlywheelLoop(opts) {
|
|
235
|
+
const { hooks } = opts;
|
|
236
|
+
// 1. Offline sanity gate — a spec that doesn't compile must never reach
|
|
237
|
+
// the (paid) eval/optimize stages.
|
|
238
|
+
hooks.compileCheck(opts.sourceYaml, "pre-optimize");
|
|
239
|
+
// 2. Baseline eval.
|
|
240
|
+
const before = await hooks.evalRun("before", opts.sourceYaml);
|
|
241
|
+
// 3. Optimize — no write-back; the patch stays in memory.
|
|
242
|
+
const optimize = await hooks.optimize();
|
|
243
|
+
if (!optimize.applied) {
|
|
244
|
+
return {
|
|
245
|
+
outcome: "no-improvement",
|
|
246
|
+
reason: "optimizer found no improvement above its threshold — spec untouched",
|
|
247
|
+
before,
|
|
248
|
+
optimize,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// 4. Post-patch compile gate: if the winning candidate somehow produced
|
|
252
|
+
// YAML the parser rejects, stop here — the source was never written.
|
|
253
|
+
try {
|
|
254
|
+
hooks.compileCheck(optimize.patchedYaml, "post-optimize");
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
return {
|
|
258
|
+
outcome: "patch-compile-failed",
|
|
259
|
+
reason: `patched spec no longer compiles: ${err instanceof Error ? err.message : String(err)}`,
|
|
260
|
+
before,
|
|
261
|
+
optimize,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// 5. After-eval on the patched spec (same dataset/graders/seed).
|
|
265
|
+
const after = await hooks.evalRun("after", optimize.patchedYaml);
|
|
266
|
+
// 6. Acceptance gate.
|
|
267
|
+
const verdict = evaluateFlywheelAcceptance(before, after);
|
|
268
|
+
if (!verdict.accepted) {
|
|
269
|
+
return { outcome: "rejected", reason: verdict.reason, verdict, before, after, optimize };
|
|
270
|
+
}
|
|
271
|
+
// 7. Accepted. --dry-run stops here — everything ran, nothing writes.
|
|
272
|
+
if (opts.dryRun) {
|
|
273
|
+
return {
|
|
274
|
+
outcome: "accepted-dry-run",
|
|
275
|
+
reason: `${verdict.reason} (--dry-run: spec NOT written)`,
|
|
276
|
+
verdict,
|
|
277
|
+
before,
|
|
278
|
+
after,
|
|
279
|
+
optimize,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
await hooks.applyAccepted(optimize);
|
|
283
|
+
return { outcome: "accepted", reason: verdict.reason, verdict, before, after, optimize };
|
|
284
|
+
}
|
|
285
|
+
// -------- report --------
|
|
286
|
+
/** One-screen flywheel report, printed after every run. */
|
|
287
|
+
export function formatFlywheelReport(result, extras) {
|
|
288
|
+
const lines = ["── flywheel report ──────────────────────────────"];
|
|
289
|
+
lines.push(`spec: ${extras.specPath}`);
|
|
290
|
+
lines.push(`dataset: ${extras.datasetName} (${extras.sampleCount} samples)`);
|
|
291
|
+
if (result.before !== undefined && result.after !== undefined) {
|
|
292
|
+
const b = result.before.aggregates.passRate;
|
|
293
|
+
const a = result.after.aggregates.passRate;
|
|
294
|
+
const delta = ((a - b) * 100).toFixed(1);
|
|
295
|
+
lines.push(`pass_rate: ${formatPassRate(b)} → ${formatPassRate(a)} (Δ ${a >= b ? "+" : ""}${delta} pts)`);
|
|
296
|
+
}
|
|
297
|
+
else if (result.before !== undefined) {
|
|
298
|
+
lines.push(`pass_rate: ${formatPassRate(result.before.aggregates.passRate)} (baseline only)`);
|
|
299
|
+
}
|
|
300
|
+
if (result.verdict !== undefined) {
|
|
301
|
+
lines.push(`samples: ${result.verdict.recoveries} recovered / ${result.verdict.regressions} regressed`);
|
|
302
|
+
}
|
|
303
|
+
if (result.optimize !== undefined) {
|
|
304
|
+
// "optimizer spend/budget", not just "spend/budget": the budget meters
|
|
305
|
+
// ONLY the optimizer's mutation model calls — eval spend (before/after
|
|
306
|
+
// acceptance evals + per-iteration fitness evals) bills outside it.
|
|
307
|
+
lines.push(`optimizer: ${result.optimize.mutatorName} · ${result.optimize.iterations} iteration(s) · ` +
|
|
308
|
+
`optimizer spend ${result.optimize.spendUsd} (optimizer budget $${extras.budgetUsd.toFixed(2)})`);
|
|
309
|
+
}
|
|
310
|
+
const verdictLabel = {
|
|
311
|
+
accepted: "ACCEPTED — patch written back",
|
|
312
|
+
"accepted-dry-run": "ACCEPTED (dry-run) — spec NOT written",
|
|
313
|
+
rejected: "REJECTED — spec untouched",
|
|
314
|
+
"no-improvement": "NO IMPROVEMENT — spec untouched",
|
|
315
|
+
"patch-compile-failed": "PATCH COMPILE FAILED — spec untouched",
|
|
316
|
+
};
|
|
317
|
+
lines.push(`verdict: ${verdictLabel[result.outcome]}`);
|
|
318
|
+
lines.push(` ${result.reason}`);
|
|
319
|
+
lines.push(`artifacts: ${extras.artifactsDir}`);
|
|
320
|
+
lines.push("─────────────────────────────────────────────────");
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
// -------- scaffolding (`flywheel init`) --------
|
|
324
|
+
export const FLYWHEEL_WORKFLOW_RELPATH = join(".github", "workflows", "crewhaus-flywheel.yml");
|
|
325
|
+
/**
|
|
326
|
+
* Write a workflow scaffold, refusing to overwrite an existing file unless
|
|
327
|
+
* `force` (shared by `flywheel init` and `init --ci`). Throws
|
|
328
|
+
* FlywheelConfigError on refusal so the CLI maps it to a clean `die()`.
|
|
329
|
+
*/
|
|
330
|
+
export function scaffoldWorkflowFile(opts) {
|
|
331
|
+
const path = join(opts.rootDir, opts.relPath);
|
|
332
|
+
if (existsSync(path) && !opts.force) {
|
|
333
|
+
throw new FlywheelConfigError(`${path} already exists — pass --force to overwrite`);
|
|
334
|
+
}
|
|
335
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
336
|
+
writeFileSync(path, opts.content);
|
|
337
|
+
return { path, action: "wrote" };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Normalize a repo-root-relative harness dir for embedding in a scaffolded
|
|
341
|
+
* workflow: POSIX separators, no leading `./`, no trailing slash; "" means
|
|
342
|
+
* the harness IS the repo root. Shared by both workflow builders.
|
|
343
|
+
*/
|
|
344
|
+
export function normalizeHarnessDir(dir) {
|
|
345
|
+
if (dir === undefined)
|
|
346
|
+
return "";
|
|
347
|
+
const cleaned = dir.replace(/\\/g, "/").replace(/\/+$/, "").replace(/^\.\//, "");
|
|
348
|
+
return cleaned === "." ? "" : cleaned;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The GitHub Actions schedule, adapted from the EP01 demo's
|
|
352
|
+
* `concierge-flywheel.yml`: nightly cron + manual dispatch, budget knobs as
|
|
353
|
+
* FLYWHEEL_* env, PR creation via `gh` for HUMAN review. It never merges on
|
|
354
|
+
* its own, and `crewhaus flywheel run` never writes an unaccepted patch, so
|
|
355
|
+
* every change that reaches the default branch passed both the acceptance
|
|
356
|
+
* gate and a human.
|
|
357
|
+
*
|
|
358
|
+
* `harnessDir` (repo-root-relative, "" = root) points the job's
|
|
359
|
+
* working-directory at a nested harness AND prefixes the artifact upload
|
|
360
|
+
* `path:` — actions paths resolve from the repo root and do NOT honor
|
|
361
|
+
* `defaults.run.working-directory` (finding 7).
|
|
362
|
+
*/
|
|
363
|
+
export function buildFlywheelWorkflowYaml(opts = {}) {
|
|
364
|
+
const sub = normalizeHarnessDir(opts.harnessDir);
|
|
365
|
+
const prefix = sub === "" ? "" : `${sub}/`;
|
|
366
|
+
const workingDirLine = sub === "" ? "" : `\n working-directory: ${sub}`;
|
|
367
|
+
const subdirNote = sub === ""
|
|
368
|
+
? `# If your harness lives in a subdirectory, re-run \`crewhaus flywheel init\`
|
|
369
|
+
# from inside it (the CLI detects the git root and prefixes everything), or
|
|
370
|
+
# hand-set jobs.flywheel.defaults.run.working-directory AND prefix the
|
|
371
|
+
# artifact upload path: — actions paths resolve from the repo root and do
|
|
372
|
+
# NOT honor working-directory. The CLI resolves the spec, dataset, graders,
|
|
373
|
+
# and .crewhaus state from the working directory (standalone-harness
|
|
374
|
+
# convention).`
|
|
375
|
+
: `# Scaffolded for the harness at ${sub}/ — the job's working-directory and
|
|
376
|
+
# the artifact upload path: are already pointed there. The CLI resolves the
|
|
377
|
+
# spec, dataset, graders, and .crewhaus state from the working directory
|
|
378
|
+
# (standalone-harness convention).`;
|
|
379
|
+
return `# crewhaus-flywheel.yml — scaffolded by \`crewhaus flywheel init\`.
|
|
380
|
+
#
|
|
381
|
+
# The nightly self-improvement loop, unattended:
|
|
382
|
+
# compile gate -> baseline eval -> optimize (budget-capped) -> post-patch
|
|
383
|
+
# compile -> after eval -> acceptance gate (pass_rate strictly up AND zero
|
|
384
|
+
# per-sample regressions) -> on accept, the spec is written back and this
|
|
385
|
+
# workflow opens a PR for HUMAN review.
|
|
386
|
+
#
|
|
387
|
+
# Invariants (do not weaken):
|
|
388
|
+
# - it NEVER merges on its own — a human is always the last gate;
|
|
389
|
+
# - the optimizer only rewrites agent.instructions (spec-patch's
|
|
390
|
+
# OPTIMIZABLE_PATHS enforces it) — \`permissions:\` stays human-reviewed;
|
|
391
|
+
# - \`crewhaus flywheel run\` applies the patch only AFTER the acceptance
|
|
392
|
+
# gate passes, so a rejected night leaves the tree byte-identical.
|
|
393
|
+
#
|
|
394
|
+
# Required repo secrets:
|
|
395
|
+
# ANTHROPIC_API_KEY — billed by eval + optimize. FLYWHEEL_BUDGET_USD caps
|
|
396
|
+
# ONLY the optimizer's mutation model calls; eval
|
|
397
|
+
# spend (before/after acceptance evals + the
|
|
398
|
+
# per-iteration fitness evals) is NOT metered by it.
|
|
399
|
+
# FLYWHEEL_GH_TOKEN — a PAT (or GitHub App token) with contents:write +
|
|
400
|
+
# pull-requests:write on this repo. Passed explicitly
|
|
401
|
+
# instead of the default GITHUB_TOKEN so the opened PR
|
|
402
|
+
# can trigger downstream CI (default-token pushes don't).
|
|
403
|
+
#
|
|
404
|
+
${subdirNote}
|
|
405
|
+
|
|
406
|
+
name: crewhaus-flywheel
|
|
407
|
+
|
|
408
|
+
on:
|
|
409
|
+
schedule:
|
|
410
|
+
# Nightly. Odd minute on purpose — avoids the top-of-hour stampede when
|
|
411
|
+
# every cron on GitHub fires at once and gets delayed.
|
|
412
|
+
- cron: "13 7 * * *"
|
|
413
|
+
workflow_dispatch:
|
|
414
|
+
inputs:
|
|
415
|
+
optimizer_budget_usd:
|
|
416
|
+
description: "Hard USD cap for the OPTIMIZER's mutation model calls this run (eval spend is billed separately, not metered by this)"
|
|
417
|
+
required: false
|
|
418
|
+
default: "2.00"
|
|
419
|
+
iterations:
|
|
420
|
+
description: "Optimizer iterations"
|
|
421
|
+
required: false
|
|
422
|
+
default: "3"
|
|
423
|
+
|
|
424
|
+
# Only one flywheel run at a time; a manual dispatch waits for a mid-flight
|
|
425
|
+
# nightly run instead of racing on the same crewhaus.yaml.
|
|
426
|
+
concurrency:
|
|
427
|
+
group: crewhaus-flywheel
|
|
428
|
+
cancel-in-progress: false
|
|
429
|
+
|
|
430
|
+
permissions:
|
|
431
|
+
contents: write # push the accepted patch on a branch
|
|
432
|
+
pull-requests: write # open the PR
|
|
433
|
+
|
|
434
|
+
jobs:
|
|
435
|
+
flywheel:
|
|
436
|
+
runs-on: ubuntu-latest
|
|
437
|
+
timeout-minutes: 30
|
|
438
|
+
defaults:
|
|
439
|
+
run:
|
|
440
|
+
shell: bash${workingDirLine}
|
|
441
|
+
steps:
|
|
442
|
+
- name: Checkout
|
|
443
|
+
uses: actions/checkout@v4
|
|
444
|
+
with:
|
|
445
|
+
fetch-depth: 0
|
|
446
|
+
token: \${{ secrets.FLYWHEEL_GH_TOKEN }}
|
|
447
|
+
|
|
448
|
+
- name: Install Bun
|
|
449
|
+
uses: oven-sh/setup-bun@v2
|
|
450
|
+
|
|
451
|
+
- name: Install crewhaus CLI
|
|
452
|
+
run: |
|
|
453
|
+
bun add -g crewhaus
|
|
454
|
+
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
|
455
|
+
|
|
456
|
+
- name: Run the flywheel
|
|
457
|
+
env:
|
|
458
|
+
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
459
|
+
# Knobs (workflow_dispatch overrides the nightly defaults). The env
|
|
460
|
+
# name is kept verbatim from the EP01 demo for continuity — the
|
|
461
|
+
# dispatch input is named for what it actually meters (see
|
|
462
|
+
# \`crewhaus flywheel --help\`).
|
|
463
|
+
FLYWHEEL_BUDGET_USD: \${{ github.event.inputs.optimizer_budget_usd || '2.00' }}
|
|
464
|
+
FLYWHEEL_ITERATIONS: \${{ github.event.inputs.iterations || '3' }}
|
|
465
|
+
FLYWHEEL_SEED: "1" # deterministic mutator => reproducible PRs
|
|
466
|
+
FLYWHEEL_CONCURRENCY: "1" # low-TPM-tier safe; raise if your key allows
|
|
467
|
+
run: crewhaus flywheel run
|
|
468
|
+
|
|
469
|
+
- name: Open a PR when the spec improved
|
|
470
|
+
env:
|
|
471
|
+
GH_TOKEN: \${{ secrets.FLYWHEEL_GH_TOKEN }}
|
|
472
|
+
run: |
|
|
473
|
+
if git diff --quiet -- crewhaus.yaml; then
|
|
474
|
+
echo "no accepted improvement tonight — nothing to open (success-by-doing-nothing)"
|
|
475
|
+
exit 0
|
|
476
|
+
fi
|
|
477
|
+
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
478
|
+
BRANCH="flywheel/\${STAMP}"
|
|
479
|
+
git config user.name "crewhaus-flywheel[bot]"
|
|
480
|
+
git config user.email "flywheel@crewhaus.ai"
|
|
481
|
+
git checkout -b "\${BRANCH}"
|
|
482
|
+
git add crewhaus.yaml
|
|
483
|
+
git commit -m "flywheel: nightly self-improvement \${STAMP}"
|
|
484
|
+
git push -u origin "\${BRANCH}"
|
|
485
|
+
# A human reviews the rewritten agent.instructions and merges.
|
|
486
|
+
# NEVER add an auto-merge step here — the human gate is the point.
|
|
487
|
+
gh pr create \\
|
|
488
|
+
--title "flywheel: nightly self-improvement (\${STAMP})" \\
|
|
489
|
+
--body "Opened by the crewhaus flywheel. The acceptance gate passed (pass_rate strictly up, zero per-sample regressions). Review the rewritten \\\`agent.instructions\\\` — \\\`permissions:\\\` were not touched. Run artifacts are attached to this workflow run."
|
|
490
|
+
|
|
491
|
+
- name: Upload run artifacts
|
|
492
|
+
# Keep before/after results + the optimize trajectory even on nights
|
|
493
|
+
# the flywheel writes nothing — that history proves the system works.
|
|
494
|
+
if: always()
|
|
495
|
+
uses: actions/upload-artifact@v4
|
|
496
|
+
with:
|
|
497
|
+
name: flywheel-run-\${{ github.run_id }}
|
|
498
|
+
# Action paths resolve from the repo root — they do NOT honor
|
|
499
|
+
# defaults.run.working-directory, hence the explicit prefix for a
|
|
500
|
+
# nested harness.
|
|
501
|
+
path: ${prefix}.crewhaus/flywheel/
|
|
502
|
+
if-no-files-found: ignore
|
|
503
|
+
retention-days: 30
|
|
504
|
+
`;
|
|
505
|
+
}
|
|
506
|
+
/** Printed after `flywheel init` (and folded into `flywheel --help`): the
|
|
507
|
+
* env knobs the scaffolded workflow steers the run with. */
|
|
508
|
+
export function formatFlywheelKnobsGuide() {
|
|
509
|
+
return [
|
|
510
|
+
"flywheel env knobs (flag > env > default — same names as the EP01 demo):",
|
|
511
|
+
` ${FLYWHEEL_ENV_KNOBS.budgetUsd}=<usd> optimizer model-call budget (default ${FLYWHEEL_DEFAULT_KNOBS.budgetUsd.toFixed(2)})`,
|
|
512
|
+
` ${FLYWHEEL_ENV_KNOBS.iterations}=<n> optimizer iterations (default ${FLYWHEEL_DEFAULT_KNOBS.iterations})`,
|
|
513
|
+
` ${FLYWHEEL_ENV_KNOBS.seed}=<n> deterministic seed (default ${FLYWHEEL_DEFAULT_KNOBS.seed})`,
|
|
514
|
+
` ${FLYWHEEL_ENV_KNOBS.concurrency}=<n> eval fan-out (default ${FLYWHEEL_DEFAULT_KNOBS.concurrency}; 30k-TPM-tier safe)`,
|
|
515
|
+
` note: ${FLYWHEEL_ENV_KNOBS.budgetUsd} caps ONLY the optimizer's mutation model calls —`,
|
|
516
|
+
" eval spend (the before/after acceptance evals and the per-iteration fitness",
|
|
517
|
+
" evals) is NOT metered by this budget.",
|
|
518
|
+
];
|
|
519
|
+
}
|