crewhaus 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +172 -0
  25. package/dist/dataset-mine.js +403 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11601 -964
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -65
@@ -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
+ }