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,157 @@
1
+ /**
2
+ * Post-eval run-history orchestration for `crewhaus eval` — append the run
3
+ * to the index, diff against the pinned (spec, dataset) baseline, gate, and
4
+ * promote. Kept in a side-effect-free module so it is unit-testable (this
5
+ * package's entry file runs an argv switch on import).
6
+ *
7
+ * Policy (AUTOMATION-OPPORTUNITIES.md item 3):
8
+ * - Every run is appended to `.crewhaus/evals/index.jsonl`.
9
+ * - First run for a (spec, dataset) key pins the baseline.
10
+ * - A keyset mismatch from `diffReports` (sample ids changed) means the
11
+ * dataset itself changed — start a new baseline lineage instead of
12
+ * failing the run.
13
+ * - The regression gate is strict by default: ANY pass-rate drop fails, and
14
+ * any sample-level pass→fail flip fails even when a recovery elsewhere
15
+ * cancels it out in the aggregates.
16
+ * - Gate pass → auto-promote the new run to baseline (opt out with
17
+ * `--no-promote`); gate fail → never promote. `--gate` only controls
18
+ * whether a failing verdict maps to a non-zero exit.
19
+ */
20
+ import { resolve } from "node:path";
21
+ import { ReportError, appendRunIndex, diffReports, getBaseline, loadRun, setBaseline, } from "@crewhaus/eval-report";
22
+ import { gate } from "@crewhaus/regression-runner";
23
+ /**
24
+ * `eval-report history|baseline show --dataset <filter>` matching. Unioned
25
+ * runs are recorded under `<primary>+regressions@vX`, so the filter matches
26
+ * a stored datasetName that equals it exactly OR continues it with a `+`
27
+ * suffix segment — `--dataset smoke` finds `smoke` and
28
+ * `smoke+regressions@v1`, but not `smoke2`.
29
+ */
30
+ export function datasetFilterMatches(filter, datasetName) {
31
+ return datasetName === filter || datasetName.startsWith(`${filter}+`);
32
+ }
33
+ /**
34
+ * Strict gate between a baseline run and a new run. Wraps
35
+ * `regression-runner`'s `gate()` with `regressionThreshold: 0` (any
36
+ * pass-rate drop fails) and additionally fails on sample-level regressions
37
+ * that net out to a flat pass rate. The latency criterion is DISABLED by
38
+ * default (threshold Infinity): the documented gate is pass-rate/flip-only,
39
+ * and regression-runner's +5000ms p95 default would fail runs the docs say
40
+ * pass. Latency gating arrives with explicit CLI flags later — callers can
41
+ * already opt in via `thresholds.latencyThreshold`.
42
+ */
43
+ export function gateRuns(prev, next, thresholds = {}) {
44
+ const verdict = gate(prev, next, {
45
+ regressionThreshold: 0,
46
+ latencyThreshold: Number.POSITIVE_INFINITY,
47
+ ...thresholds,
48
+ });
49
+ if (verdict.verdict === "pass" && verdict.report.regressions.length > 0) {
50
+ const ids = verdict.report.regressions.map((r) => r.sampleId).join(", ");
51
+ return {
52
+ verdict: "fail",
53
+ reason: `sample regressions despite a flat pass rate: [${ids}]`,
54
+ report: verdict.report,
55
+ };
56
+ }
57
+ return verdict;
58
+ }
59
+ /**
60
+ * Record a completed eval run in the history index, then resolve/diff/gate
61
+ * against the pinned baseline for its (spec, dataset) key. Returns whether
62
+ * the caller should exit non-zero (gate requested + failed).
63
+ */
64
+ export async function finishEvalRun(opts) {
65
+ const write = opts.write ?? ((line) => process.stdout.write(`${line}\n`));
66
+ const { summary, specName } = opts;
67
+ const datasetName = summary.config.datasetName;
68
+ const absOut = resolve(opts.outDir);
69
+ // Belt (the runner already refuses to run zero samples): a 0-sample run
70
+ // carries no signal and a passRate-0 "clean" entry would poison the index
71
+ // and could pin an empty baseline — refuse loudly instead of recording it.
72
+ if (summary.samples.length === 0) {
73
+ throw new Error(`refusing to record 0-sample eval run ${summary.runId} — dataset "${datasetName}" produced no samples`);
74
+ }
75
+ const entry = {
76
+ runId: summary.runId,
77
+ specName,
78
+ specHash: summary.config.specHash,
79
+ datasetName,
80
+ datasetHash: opts.datasetHash,
81
+ passRate: summary.aggregates.passRate,
82
+ meanScore: summary.aggregates.meanScore,
83
+ sampleCount: summary.samples.length,
84
+ retriedCount: summary.samples.filter((s) => s.retried === true).length,
85
+ ts: summary.endedAt,
86
+ outDir: absOut,
87
+ };
88
+ appendRunIndex(entry, opts.evalsDir);
89
+ const pinCurrentRun = (label) => {
90
+ if (!opts.promote) {
91
+ write(`[eval] baseline not set (${label}; --no-promote)`);
92
+ return;
93
+ }
94
+ const pin = {
95
+ specName,
96
+ datasetName,
97
+ runId: summary.runId,
98
+ outDir: absOut,
99
+ datasetHash: opts.datasetHash,
100
+ ts: entry.ts,
101
+ };
102
+ setBaseline(pin, opts.evalsDir);
103
+ write(`[eval] baseline set: ${summary.runId} (${label})`);
104
+ };
105
+ const baseline = getBaseline(specName, datasetName, opts.evalsDir);
106
+ if (baseline === undefined) {
107
+ pinCurrentRun(`first run for ${specName}/${datasetName}`);
108
+ return { gateFailed: false };
109
+ }
110
+ let prevLoaded;
111
+ try {
112
+ prevLoaded = await loadRun(baseline.outDir);
113
+ }
114
+ catch (err) {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ write(`[eval] baseline run ${baseline.runId} unreadable (${msg}) — starting new baseline lineage`);
117
+ pinCurrentRun("new lineage");
118
+ return { gateFailed: false };
119
+ }
120
+ const nextLoaded = await loadRun(absOut);
121
+ let diff;
122
+ try {
123
+ diff = diffReports(prevLoaded, nextLoaded).diff;
124
+ }
125
+ catch (err) {
126
+ if (err instanceof ReportError) {
127
+ write("[eval] dataset changed — starting new baseline lineage");
128
+ pinCurrentRun("new lineage");
129
+ return { gateFailed: false };
130
+ }
131
+ throw err;
132
+ }
133
+ write(`[eval] vs baseline ${baseline.runId}: regressions=${diff.regressions.length} ` +
134
+ `recoveries=${diff.recoveries.length} score_shifts=${diff.scoreShifts.length} ` +
135
+ `unchanged=${diff.unchanged}`);
136
+ for (const r of diff.regressions) {
137
+ write(`[eval] regression: ${r.sampleId} (score ${r.prev.score.toFixed(2)} → ${r.next.score.toFixed(2)})`);
138
+ }
139
+ for (const r of diff.recoveries) {
140
+ write(`[eval] recovery: ${r.sampleId} (score ${r.prev.score.toFixed(2)} → ${r.next.score.toFixed(2)})`);
141
+ }
142
+ const verdict = gateRuns(prevLoaded.summary, nextLoaded.summary);
143
+ if (verdict.verdict === "fail") {
144
+ const reason = verdict.reason ?? "regression gate failed";
145
+ write(`[eval] gate: FAIL — ${reason}`);
146
+ write(`[eval] baseline kept: ${baseline.runId}`);
147
+ return opts.gateRequested ? { gateFailed: true, gateReason: reason } : { gateFailed: false };
148
+ }
149
+ write("[eval] gate: PASS");
150
+ if (opts.promote) {
151
+ pinCurrentRun("promoted");
152
+ }
153
+ else {
154
+ write(`[eval] baseline kept: ${baseline.runId} (--no-promote)`);
155
+ }
156
+ return { gateFailed: false };
157
+ }
@@ -0,0 +1,80 @@
1
+ import type { MatrixCell, MatrixPricingFn } from "@crewhaus/eval-report";
2
+ import type { EvalRunSummary } from "@crewhaus/eval-runner";
3
+ /** Thrown on a malformed `--models` value or an incompatible flag combo.
4
+ * The CLI entry file catches it and routes the message through `die()`;
5
+ * tests assert on `.message` without the process exiting. */
6
+ export declare class MatrixArgError extends Error {
7
+ readonly name = "MatrixArgError";
8
+ }
9
+ /**
10
+ * Parse `--models m1,m2,...`: split on commas, trim whitespace, drop empty
11
+ * segments (trailing-comma tolerance), reject duplicates, and validate
12
+ * every entry against the model-router grammar UP FRONT — a typo must fail
13
+ * before cell 1 burns a single token.
14
+ */
15
+ export declare function parseModelsFlag(value: string): string[];
16
+ /**
17
+ * `--gate` / `--no-promote` steer the item-3 baseline lineage, which matrix
18
+ * cells skip entirely — reject the combination instead of silently ignoring
19
+ * the flags.
20
+ */
21
+ export declare function assertMatrixFlagsCompatible(flags: {
22
+ readonly gate: boolean;
23
+ readonly noPromote: boolean;
24
+ }): void;
25
+ /**
26
+ * Filesystem-safe cell directory name for a model string: every character
27
+ * outside `[A-Za-z0-9._-]` collapses to a single `_` (so the slug stays
28
+ * readable/reversible-enough: `openai/gpt-4o` → `openai_gpt-4o`,
29
+ * `local/llama3.2@http://localhost:11434/v1` →
30
+ * `local_llama3.2_http_localhost_11434_v1`). A slug with no alphanumeric
31
+ * left (nothing a path could safely be named after — e.g. all dots) falls
32
+ * back to a stable content-hash name.
33
+ */
34
+ export declare function modelSlug(model: string): string;
35
+ /**
36
+ * model → cell-directory slug for the whole matrix. Distinct models whose
37
+ * slugs collide (e.g. `openai/gpt.4o` vs `openai/gpt_4o`) get a `_2`, `_3`,
38
+ * … suffix in list order, so every cell keeps its own directory.
39
+ */
40
+ export declare function assignCellSlugs(models: ReadonlyArray<string>): Map<string, string>;
41
+ /**
42
+ * The real `MatrixPricingFn` for eval-report's matrix renderer: model
43
+ * string → provider via the model-router grammar, then cost-tracker's
44
+ * versioned pricing table over the cell's token totals (cached-read tokens
45
+ * are 0 — eval aggregates don't track them). Any miss — unparseable model,
46
+ * unknown provider row (e.g. a groq/ or local/ model billed elsewhere) —
47
+ * returns `undefined`, which renders as "n/a" rather than crashing.
48
+ */
49
+ export declare function defaultMatrixPricing(): MatrixPricingFn;
50
+ export type RunMatrixCellsOptions = {
51
+ readonly models: ReadonlyArray<string>;
52
+ /** model → cell directory name (see {@link assignCellSlugs}). */
53
+ readonly slugs: ReadonlyMap<string, string>;
54
+ /** Matrix root; each cell runs in `<rootDir>/<slug>`. */
55
+ readonly rootDir: string;
56
+ /** Execute one cell's eval and return its summary. Injected so tests can
57
+ * stub the runner; the CLI passes a `runEval` wrapper that patches the
58
+ * lowered ir's `agent.model` in-memory (mirroring `run --model`). */
59
+ readonly runCell: (model: string, cellOutDir: string) => Promise<EvalRunSummary>;
60
+ /** Line sink; defaults to stdout. */
61
+ readonly write?: (line: string) => void;
62
+ };
63
+ /**
64
+ * "The cell never really ran": eval-runner isolates per-sample invoker
65
+ * errors (a summary always comes back), so a bad credential / 404 model
66
+ * surfaces as EVERY sample erroring rather than as a throw. Map that to a
67
+ * cell failure — its 0% pass rate and 0ms latencies are artifacts of never
68
+ * producing output, not a comparison result. Partial sample errors (a 529
69
+ * blip on 1 of 20) leave the cell a normal "ran with failing samples" row.
70
+ */
71
+ export declare function cellCrashReason(summary: EvalRunSummary): string | undefined;
72
+ /**
73
+ * Run every cell sequentially, isolating failures: one model erroring (bad
74
+ * credentials, 404 model, …) — whether thrown or absorbed by the runner as
75
+ * all-samples-errored (see {@link cellCrashReason}) — records an error cell
76
+ * and the loop continues. The caller maps "any error cell" to a non-zero
77
+ * exit AFTER rendering the matrix — a crashed cell is distinct from a cell
78
+ * that ran with failing samples, which is a normal result.
79
+ */
80
+ export declare function runMatrixCells(opts: RunMatrixCellsOptions): Promise<MatrixCell[]>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Item 11 — `crewhaus eval --models <m1,m2,...>`: one-command model
3
+ * benchmark matrix. The CLI resolves the dataset (registry ref or file,
4
+ * item-9 regression union included) ONCE, then runs the same samples +
5
+ * graders once per model, each cell writing a full run directory to
6
+ * `<out>/<model-slug>/` so `eval-report diff` works on any pair of cells.
7
+ *
8
+ * Deliberate non-interactions with the item-3 run-history features:
9
+ * matrix cells are model comparisons, not lineage runs — they never touch
10
+ * `finishEvalRun` (index append / baseline pin / gate / promote), because a
11
+ * shared (spec, dataset) baseline key across N models would corrupt the
12
+ * lineage. `--gate` / `--no-promote` are rejected up front for the same
13
+ * reason (see {@link assertMatrixFlagsCompatible}).
14
+ *
15
+ * Kept in a side-effect-free module (the CLI entry file runs an argv
16
+ * switch on import) mirroring `eval-history.ts` / `datasets.ts`: the
17
+ * per-cell eval execution is injected as `runCell`, so failure isolation
18
+ * is unit-testable without an LLM.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { join } from "node:path";
22
+ import { DEFAULT_PRICING, computeCostMicros, resolvePricing } from "@crewhaus/cost-tracker";
23
+ import { parseModelString } from "@crewhaus/model-router";
24
+ /** Thrown on a malformed `--models` value or an incompatible flag combo.
25
+ * The CLI entry file catches it and routes the message through `die()`;
26
+ * tests assert on `.message` without the process exiting. */
27
+ export class MatrixArgError extends Error {
28
+ name = "MatrixArgError";
29
+ }
30
+ // -------- flag parsing / validation --------
31
+ /**
32
+ * Parse `--models m1,m2,...`: split on commas, trim whitespace, drop empty
33
+ * segments (trailing-comma tolerance), reject duplicates, and validate
34
+ * every entry against the model-router grammar UP FRONT — a typo must fail
35
+ * before cell 1 burns a single token.
36
+ */
37
+ export function parseModelsFlag(value) {
38
+ const models = value
39
+ .split(",")
40
+ .map((m) => m.trim())
41
+ .filter((m) => m.length > 0);
42
+ if (models.length === 0) {
43
+ throw new MatrixArgError("--models: expected a comma-separated list of model strings (e.g. claude-sonnet-4-5,openai/gpt-4o)");
44
+ }
45
+ const seen = new Set();
46
+ for (const model of models) {
47
+ if (seen.has(model)) {
48
+ throw new MatrixArgError(`--models: duplicate model "${model}"`);
49
+ }
50
+ seen.add(model);
51
+ try {
52
+ parseModelString(model);
53
+ }
54
+ catch (err) {
55
+ const msg = err instanceof Error ? err.message : String(err);
56
+ throw new MatrixArgError(`--models: ${msg}`);
57
+ }
58
+ }
59
+ return models;
60
+ }
61
+ /**
62
+ * `--gate` / `--no-promote` steer the item-3 baseline lineage, which matrix
63
+ * cells skip entirely — reject the combination instead of silently ignoring
64
+ * the flags.
65
+ */
66
+ export function assertMatrixFlagsCompatible(flags) {
67
+ if (flags.gate) {
68
+ throw new MatrixArgError("--models is incompatible with --gate — matrix cells are model comparisons and skip the (spec, dataset) baseline lineage; gate a single-model eval instead");
69
+ }
70
+ if (flags.noPromote) {
71
+ throw new MatrixArgError("--models is incompatible with --no-promote — matrix cells never touch the run index or baselines, so there is nothing to promote");
72
+ }
73
+ }
74
+ // -------- slugs --------
75
+ /**
76
+ * Filesystem-safe cell directory name for a model string: every character
77
+ * outside `[A-Za-z0-9._-]` collapses to a single `_` (so the slug stays
78
+ * readable/reversible-enough: `openai/gpt-4o` → `openai_gpt-4o`,
79
+ * `local/llama3.2@http://localhost:11434/v1` →
80
+ * `local_llama3.2_http_localhost_11434_v1`). A slug with no alphanumeric
81
+ * left (nothing a path could safely be named after — e.g. all dots) falls
82
+ * back to a stable content-hash name.
83
+ */
84
+ export function modelSlug(model) {
85
+ const slug = model.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
86
+ if (!/[A-Za-z0-9]/.test(slug)) {
87
+ return `model_${createHash("sha256").update(model).digest("hex").slice(0, 8)}`;
88
+ }
89
+ return slug;
90
+ }
91
+ /**
92
+ * model → cell-directory slug for the whole matrix. Distinct models whose
93
+ * slugs collide (e.g. `openai/gpt.4o` vs `openai/gpt_4o`) get a `_2`, `_3`,
94
+ * … suffix in list order, so every cell keeps its own directory.
95
+ */
96
+ export function assignCellSlugs(models) {
97
+ const taken = new Set();
98
+ const out = new Map();
99
+ for (const model of models) {
100
+ const base = modelSlug(model);
101
+ let candidate = base;
102
+ for (let i = 2; taken.has(candidate); i += 1)
103
+ candidate = `${base}_${i}`;
104
+ taken.add(candidate);
105
+ out.set(model, candidate);
106
+ }
107
+ return out;
108
+ }
109
+ // -------- pricing seam --------
110
+ /**
111
+ * The real `MatrixPricingFn` for eval-report's matrix renderer: model
112
+ * string → provider via the model-router grammar, then cost-tracker's
113
+ * versioned pricing table over the cell's token totals (cached-read tokens
114
+ * are 0 — eval aggregates don't track them). Any miss — unparseable model,
115
+ * unknown provider row (e.g. a groq/ or local/ model billed elsewhere) —
116
+ * returns `undefined`, which renders as "n/a" rather than crashing.
117
+ */
118
+ export function defaultMatrixPricing() {
119
+ return (model, tokens) => {
120
+ try {
121
+ const parsed = parseModelString(model);
122
+ const row = resolvePricing(DEFAULT_PRICING, parsed.providerId, parsed.modelId);
123
+ if (row === undefined)
124
+ return undefined;
125
+ return computeCostMicros(row, tokens.input, tokens.output, 0);
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ };
131
+ }
132
+ /**
133
+ * "The cell never really ran": eval-runner isolates per-sample invoker
134
+ * errors (a summary always comes back), so a bad credential / 404 model
135
+ * surfaces as EVERY sample erroring rather than as a throw. Map that to a
136
+ * cell failure — its 0% pass rate and 0ms latencies are artifacts of never
137
+ * producing output, not a comparison result. Partial sample errors (a 529
138
+ * blip on 1 of 20) leave the cell a normal "ran with failing samples" row.
139
+ */
140
+ export function cellCrashReason(summary) {
141
+ const n = summary.samples.length;
142
+ if (n === 0 || summary.aggregates.errorCount < n)
143
+ return undefined;
144
+ const first = summary.samples.find((s) => s.error !== undefined)?.error;
145
+ return `all ${n} sample(s) errored${first !== undefined ? ` (first: ${first})` : ""}`;
146
+ }
147
+ /**
148
+ * Run every cell sequentially, isolating failures: one model erroring (bad
149
+ * credentials, 404 model, …) — whether thrown or absorbed by the runner as
150
+ * all-samples-errored (see {@link cellCrashReason}) — records an error cell
151
+ * and the loop continues. The caller maps "any error cell" to a non-zero
152
+ * exit AFTER rendering the matrix — a crashed cell is distinct from a cell
153
+ * that ran with failing samples, which is a normal result.
154
+ */
155
+ export async function runMatrixCells(opts) {
156
+ const write = opts.write ?? ((line) => process.stdout.write(`${line}\n`));
157
+ const cells = [];
158
+ for (const model of opts.models) {
159
+ const slug = opts.slugs.get(model) ?? modelSlug(model);
160
+ const outDir = join(opts.rootDir, slug);
161
+ write(`[eval] cell ${model} → ${outDir}`);
162
+ try {
163
+ const summary = await opts.runCell(model, outDir);
164
+ const crashed = cellCrashReason(summary);
165
+ if (crashed !== undefined) {
166
+ cells.push({ model, slug, outDir, error: crashed });
167
+ write(`[eval] cell FAILED (${crashed}) — continuing with remaining models`);
168
+ continue;
169
+ }
170
+ cells.push({ model, slug, outDir, summary });
171
+ write(`[eval] pass_rate=${(summary.aggregates.passRate * 100).toFixed(1)}% ` +
172
+ `mean_score=${summary.aggregates.meanScore.toFixed(3)} ` +
173
+ `errors=${summary.aggregates.errorCount}`);
174
+ }
175
+ catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ cells.push({ model, slug, outDir, error: msg });
178
+ write(`[eval] cell FAILED (${msg}) — continuing with remaining models`);
179
+ }
180
+ }
181
+ return cells;
182
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Item 30 — nightly model-drift sentinel evals against a FROZEN baseline.
3
+ *
4
+ * `crewhaus eval --sentinel --baseline <run-dir>` re-runs a seed-pinned
5
+ * sentinel dataset against the UNCHANGED spec, then compares the fresh run to
6
+ * a stored baseline run. The premise: when the spec (specHash), the dataset
7
+ * (content hash), the judge model, AND the graders config (content hash) are
8
+ * ALL byte-identical to the baseline, ANY pass/fail flip or score shift can
9
+ * only be attributed to the PROVIDER silently changing model behaviour — the
10
+ * one variable the harness did not hold fixed. So the sentinel:
11
+ *
12
+ * 1. asserts `specHash` equality (baseline vs fresh run) — a mismatch means
13
+ * the spec changed, so this is NOT a clean drift probe: report and exit
14
+ * non-zero so a mis-pointed sentinel is loud rather than silently green.
15
+ * 2. asserts DATASET-HASH equality. run.json only records `datasetName`, so
16
+ * the caller sha256's the dataset content itself and passes both hashes;
17
+ * a mismatch (different sentinel data) is likewise NOT a clean probe.
18
+ * 3. asserts `config.judgeModel` equality AND `config.gradersHash` equality
19
+ * (sha256 of the parsed GradersConfig) — a different `--judge-model` or
20
+ * an edited graders.yaml changes what "pass" MEANS independent of the
21
+ * provider under test, so either mismatch is likewise NOT a clean probe
22
+ * (F2: without this, a judge/grader change silently reads as "provider
23
+ * drift" because neither ever touched specHash or the dataset).
24
+ * 4. with every hash equal, diffs the two runs (`diffReports`) and gates on
25
+ * the strict regression-runner defaults. Any regression or score shift is
26
+ * flagged as provider drift; the command exits non-zero so a CI cron /
27
+ * scheduler alerts.
28
+ *
29
+ * Side-effect-free (this package's entry file runs an argv switch on import):
30
+ * the fresh run + baseline load are performed by the caller and passed in as
31
+ * `LoadedRun`s, so the drift logic is unit-testable with seeded run dirs.
32
+ */
33
+ import { type LoadedRun, type ReportDiff } from "@crewhaus/eval-report";
34
+ export type SentinelVerdict = "clean" | "drift" | "not-comparable";
35
+ export type SentinelResult = {
36
+ readonly verdict: SentinelVerdict;
37
+ /** True when the caller should exit non-zero (drift OR not-comparable). */
38
+ readonly alert: boolean;
39
+ readonly reason: string;
40
+ /** The score/flip diff, present only when the runs were comparable. */
41
+ readonly diff?: ReportDiff;
42
+ };
43
+ /**
44
+ * Compare a fresh run against a frozen baseline and decide whether the
45
+ * provider drifted. `baselineDatasetHash` / `currentDatasetHash` are the
46
+ * sha256 of the dataset CONTENT (run.json records only `datasetName`), so the
47
+ * caller hashes the dataset file/registry record and passes both in.
48
+ *
49
+ * Verdicts:
50
+ * - `not-comparable` (alert): specHash, dataset-hash, judgeModel, or
51
+ * gradersHash differs — the spec, the sentinel data, or what "pass" means
52
+ * changed, so a diff cannot be attributed to the provider. Loud, not
53
+ * silent: a mis-pointed sentinel must not read green.
54
+ * - `drift` (alert): every hash/model equal AND the diff shows a regression
55
+ * or a score shift ⇒ the provider silently changed model behaviour.
56
+ * - `clean` (no alert): every hash/model equal AND no regression / no score
57
+ * shift (recoveries alone are not drift-alerting — a provider getting
58
+ * BETTER on frozen inputs is still worth noting but is not a failure).
59
+ */
60
+ export declare function evaluateSentinel(opts: {
61
+ readonly baseline: LoadedRun;
62
+ readonly current: LoadedRun;
63
+ readonly baselineDatasetHash: string;
64
+ readonly currentDatasetHash: string;
65
+ }): SentinelResult;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Item 30 — nightly model-drift sentinel evals against a FROZEN baseline.
3
+ *
4
+ * `crewhaus eval --sentinel --baseline <run-dir>` re-runs a seed-pinned
5
+ * sentinel dataset against the UNCHANGED spec, then compares the fresh run to
6
+ * a stored baseline run. The premise: when the spec (specHash), the dataset
7
+ * (content hash), the judge model, AND the graders config (content hash) are
8
+ * ALL byte-identical to the baseline, ANY pass/fail flip or score shift can
9
+ * only be attributed to the PROVIDER silently changing model behaviour — the
10
+ * one variable the harness did not hold fixed. So the sentinel:
11
+ *
12
+ * 1. asserts `specHash` equality (baseline vs fresh run) — a mismatch means
13
+ * the spec changed, so this is NOT a clean drift probe: report and exit
14
+ * non-zero so a mis-pointed sentinel is loud rather than silently green.
15
+ * 2. asserts DATASET-HASH equality. run.json only records `datasetName`, so
16
+ * the caller sha256's the dataset content itself and passes both hashes;
17
+ * a mismatch (different sentinel data) is likewise NOT a clean probe.
18
+ * 3. asserts `config.judgeModel` equality AND `config.gradersHash` equality
19
+ * (sha256 of the parsed GradersConfig) — a different `--judge-model` or
20
+ * an edited graders.yaml changes what "pass" MEANS independent of the
21
+ * provider under test, so either mismatch is likewise NOT a clean probe
22
+ * (F2: without this, a judge/grader change silently reads as "provider
23
+ * drift" because neither ever touched specHash or the dataset).
24
+ * 4. with every hash equal, diffs the two runs (`diffReports`) and gates on
25
+ * the strict regression-runner defaults. Any regression or score shift is
26
+ * flagged as provider drift; the command exits non-zero so a CI cron /
27
+ * scheduler alerts.
28
+ *
29
+ * Side-effect-free (this package's entry file runs an argv switch on import):
30
+ * the fresh run + baseline load are performed by the caller and passed in as
31
+ * `LoadedRun`s, so the drift logic is unit-testable with seeded run dirs.
32
+ */
33
+ import { ReportError, diffReports } from "@crewhaus/eval-report";
34
+ /**
35
+ * Compare a fresh run against a frozen baseline and decide whether the
36
+ * provider drifted. `baselineDatasetHash` / `currentDatasetHash` are the
37
+ * sha256 of the dataset CONTENT (run.json records only `datasetName`), so the
38
+ * caller hashes the dataset file/registry record and passes both in.
39
+ *
40
+ * Verdicts:
41
+ * - `not-comparable` (alert): specHash, dataset-hash, judgeModel, or
42
+ * gradersHash differs — the spec, the sentinel data, or what "pass" means
43
+ * changed, so a diff cannot be attributed to the provider. Loud, not
44
+ * silent: a mis-pointed sentinel must not read green.
45
+ * - `drift` (alert): every hash/model equal AND the diff shows a regression
46
+ * or a score shift ⇒ the provider silently changed model behaviour.
47
+ * - `clean` (no alert): every hash/model equal AND no regression / no score
48
+ * shift (recoveries alone are not drift-alerting — a provider getting
49
+ * BETTER on frozen inputs is still worth noting but is not a failure).
50
+ */
51
+ export function evaluateSentinel(opts) {
52
+ const baseSpecHash = opts.baseline.summary.config.specHash;
53
+ const curSpecHash = opts.current.summary.config.specHash;
54
+ if (baseSpecHash !== curSpecHash) {
55
+ return {
56
+ verdict: "not-comparable",
57
+ alert: true,
58
+ reason: `spec changed since the baseline (specHash ${short(baseSpecHash)} → ${short(curSpecHash)}) — a sentinel probe requires an UNCHANGED spec; re-pin the baseline against the current spec`,
59
+ };
60
+ }
61
+ if (opts.baselineDatasetHash !== opts.currentDatasetHash) {
62
+ return {
63
+ verdict: "not-comparable",
64
+ alert: true,
65
+ reason: `sentinel dataset changed since the baseline (datasetHash ${short(opts.baselineDatasetHash)} → ${short(opts.currentDatasetHash)}) — a sentinel probe requires byte-identical data; re-pin the baseline against the current dataset`,
66
+ };
67
+ }
68
+ // F2 — a different judge model or an edited graders.yaml changes what
69
+ // "pass" MEANS independent of the provider under test. Neither touches
70
+ // specHash or the dataset hash, so without this check the sentinel would
71
+ // misattribute the resulting score shift to "provider drift".
72
+ const baseJudgeModel = opts.baseline.summary.config.judgeModel;
73
+ const curJudgeModel = opts.current.summary.config.judgeModel;
74
+ if (baseJudgeModel !== curJudgeModel) {
75
+ return {
76
+ verdict: "not-comparable",
77
+ alert: true,
78
+ reason: `judge model changed since the baseline (judgeModel ${judgeModelLabel(baseJudgeModel)} → ${judgeModelLabel(curJudgeModel)}) — a sentinel probe requires an UNCHANGED judge model; re-pin the baseline against the current --judge-model`,
79
+ };
80
+ }
81
+ const baseGradersHash = opts.baseline.summary.config.gradersHash;
82
+ const curGradersHash = opts.current.summary.config.gradersHash;
83
+ if (baseGradersHash !== curGradersHash) {
84
+ return {
85
+ verdict: "not-comparable",
86
+ alert: true,
87
+ reason: `graders config changed since the baseline (gradersHash ${short(baseGradersHash)} → ${short(curGradersHash)}) — a sentinel probe requires byte-identical graders; re-pin the baseline against the current graders.yaml`,
88
+ };
89
+ }
90
+ let diff;
91
+ try {
92
+ diff = diffReports(opts.baseline, opts.current).diff;
93
+ }
94
+ catch (err) {
95
+ if (err instanceof ReportError) {
96
+ // Same specHash + dataset-hash but a keyset mismatch is a contradiction
97
+ // (identical data can't change sample ids) — surface it as not-comparable
98
+ // rather than crash the nightly job.
99
+ return {
100
+ verdict: "not-comparable",
101
+ alert: true,
102
+ reason: `sentinel diff failed despite equal hashes: ${err.message}`,
103
+ };
104
+ }
105
+ throw err;
106
+ }
107
+ const drifted = diff.regressions.length > 0 || diff.scoreShifts.length > 0;
108
+ if (drifted) {
109
+ return {
110
+ verdict: "drift",
111
+ alert: true,
112
+ reason: `provider drift on a frozen spec+dataset+judge+graders: ${diff.regressions.length} regression(s), ` +
113
+ `${diff.scoreShifts.length} score-shift(s), ${diff.recoveries.length} recovery(-ies) ` +
114
+ `(specHash ${short(baseSpecHash)}, datasetHash ${short(opts.baselineDatasetHash)} unchanged)`,
115
+ diff,
116
+ };
117
+ }
118
+ return {
119
+ verdict: "clean",
120
+ alert: false,
121
+ reason: `no drift: spec+dataset+judge+graders frozen and every sample held its verdict (${diff.recoveries.length} recovery(-ies), ${diff.unchanged} unchanged)`,
122
+ diff,
123
+ };
124
+ }
125
+ function judgeModelLabel(model) {
126
+ return model === undefined ? "(default)" : model;
127
+ }
128
+ function short(hash) {
129
+ if (hash === undefined)
130
+ return "(none)";
131
+ return hash.length > 12 ? `${hash.slice(0, 12)}…` : hash;
132
+ }