crewhaus 0.1.7 → 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 +246 -0
  51. package/dist/feedback.js +612 -0
  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 +11727 -740
  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 -64
@@ -0,0 +1,140 @@
1
+ import type { LoggedEvent, SessionTurn } from "./feedback";
2
+ /** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
3
+ * through `die()`; tests assert on `.message` without the process exiting. */
4
+ export declare class EvalCoverageError extends Error {
5
+ readonly name = "EvalCoverageError";
6
+ }
7
+ /** How many sessions feed the distribution by default (most-recent first). */
8
+ export declare const DEFAULT_COVERAGE_SESSIONS = 50;
9
+ export type CoverageFormat = "text" | "html" | "json";
10
+ /** Parse `--format`; defaults to text. */
11
+ export declare function parseCoverageFormat(value: string | undefined): CoverageFormat;
12
+ /** Parse `--sessions`: a positive integer or `all`. */
13
+ export declare function parseSessionsFlag(value: string | undefined): number | "all";
14
+ /** The tool names an `assistant_message` payload called, verbatim (PascalCase
15
+ * builtins, `mcp__`-prefixed MCP tools), in first-seen order. */
16
+ export declare function assistantToolNames(payload: unknown): string[];
17
+ /** True when a tool name is an MCP tool (verbatim `mcp__`-prefixed). */
18
+ export declare function isMcpTool(name: string): boolean;
19
+ export type ProdBehavior = {
20
+ /** Sessions scanned. */
21
+ readonly sessionCount: number;
22
+ /** tool name → number of SESSIONS in which it was called at least once. */
23
+ readonly toolSessions: ReadonlyMap<string, number>;
24
+ /** tool name → total call count across all sessions. */
25
+ readonly toolCalls: ReadonlyMap<string, number>;
26
+ /** ordered `A B` tool bigram → number of sessions it appeared in. */
27
+ readonly bigramSessions: ReadonlyMap<string, number>;
28
+ /** Number of sessions in which a `compaction` event fired. */
29
+ readonly compactionSessions: number;
30
+ /** Deterministic clusters of user-input themes. */
31
+ readonly inputThemes: ReadonlyArray<InputTheme>;
32
+ };
33
+ export type InputTheme = {
34
+ /** Top tokens joined — the human handle. */
35
+ readonly label: string;
36
+ readonly tokens: ReadonlyArray<string>;
37
+ /** How many user inputs fell in this cluster. */
38
+ readonly count: number;
39
+ /** A representative input (shortest, for a compact exemplar). */
40
+ readonly exemplar: string;
41
+ };
42
+ /** Split a bigram key back into its two tool names. */
43
+ export declare function splitBigram(key: string): [string, string];
44
+ /**
45
+ * Build the production behavior distribution from a set of sessions' parsed
46
+ * event arrays. Each session contributes at most once to the per-session
47
+ * counts (tool_sessions / bigram_sessions / compaction), so a chatty session
48
+ * cannot dominate the frequency ranking; raw call totals are kept separately.
49
+ * User inputs are clustered by the same deterministic token overlap
50
+ * `graders-suggest` uses.
51
+ */
52
+ export declare function buildProdBehavior(sessions: ReadonlyArray<{
53
+ sessionId: string;
54
+ events: ReadonlyArray<LoggedEvent>;
55
+ }>): ProdBehavior;
56
+ /**
57
+ * Deterministic greedy clustering of user inputs by normalized token overlap
58
+ * — the same seed-comparison algorithm `graders-suggest.clusterFailures`
59
+ * uses, inlined here over plain strings. Inputs are visited in a stable order
60
+ * (text asc); themes come back largest-first, labelled by their most frequent
61
+ * tokens. Inputs with no clusterable tokens are dropped.
62
+ */
63
+ export declare function clusterInputs(inputs: ReadonlyArray<string>, threshold?: number): InputTheme[];
64
+ export type EvalCoverage = {
65
+ /** Every tool named in a Sample's `expected_tools`, or observed in the most
66
+ * recent eval run's per-sample events. Compared against prod, so the set
67
+ * IS the coverage. */
68
+ readonly toolsExercised: ReadonlySet<string>;
69
+ /** Ordered `A B` tool bigrams present in any Sample's expected_tools
70
+ * sequence, or the eval run's per-sample tool stream. */
71
+ readonly bigramsExercised: ReadonlySet<string>;
72
+ /** Number of dataset samples inspected. */
73
+ readonly sampleCount: number;
74
+ /** Whether a recent eval run's per-sample events were available. */
75
+ readonly hasRunEvents: boolean;
76
+ /** Whether any sample declared expected_tools. */
77
+ readonly hasExpectedTools: boolean;
78
+ };
79
+ /** Minimal sample view the coverage computation needs. */
80
+ export type CoverageSample = {
81
+ readonly expected_tools?: ReadonlyArray<string>;
82
+ };
83
+ /**
84
+ * Build the eval-coverage distribution from the dataset's `expected_tools` and
85
+ * (optionally) the most recent eval run's per-sample `events.jsonl` texts.
86
+ * Both a sample's expected_tools sequence and a run's observed tool stream
87
+ * contribute their ordered bigrams, so a bigram is "exercised" if EITHER the
88
+ * dataset asserts it or a real eval run walked it.
89
+ */
90
+ export declare function buildEvalCoverage(samples: ReadonlyArray<CoverageSample>, runEventTexts: ReadonlyArray<string>): EvalCoverage;
91
+ export type CoverageGapKind = "tool" | "mcp-tool" | "bigram" | "compaction";
92
+ export type CoverageGap = {
93
+ readonly kind: CoverageGapKind;
94
+ /** Human-readable subject: a tool name, `A → B` bigram, or "compaction". */
95
+ readonly subject: string;
96
+ /** Sessions the behavior appeared in. */
97
+ readonly sessions: number;
98
+ /** Fraction of scanned sessions (0..1). */
99
+ readonly fraction: number;
100
+ /** One-line rationale for the report. */
101
+ readonly detail: string;
102
+ };
103
+ export type CoverageReport = {
104
+ readonly specName?: string;
105
+ readonly sessionsScanned: number;
106
+ readonly datasetName?: string;
107
+ readonly sampleCount: number;
108
+ readonly hasRunEvents: boolean;
109
+ /** Ranked (by session frequency desc) production behaviors with no eval. */
110
+ readonly gaps: ReadonlyArray<CoverageGap>;
111
+ /** Input themes with no obviously-covering sample (advisory — inputs aren't
112
+ * directly intersected, so these are surfaced, not counted as hard gaps). */
113
+ readonly inputThemes: ReadonlyArray<InputTheme>;
114
+ };
115
+ /**
116
+ * Intersect the production distribution with the eval coverage and rank the
117
+ * gaps. A tool/bigram is a gap when it appears in ≥1 production session but is
118
+ * never exercised by the dataset or a recent eval run. Compaction is a gap
119
+ * when it fired in prod but no run event stream shows it was exercised (eval
120
+ * runs don't compact, so any prod compaction is by definition uncovered —
121
+ * surfaced whenever it fired at all). Gaps are ranked by session frequency
122
+ * desc, then subject asc for stability.
123
+ */
124
+ export declare function computeCoverage(opts: {
125
+ prod: ProdBehavior;
126
+ evalCov: EvalCoverage;
127
+ specName?: string;
128
+ datasetName?: string;
129
+ }): CoverageReport;
130
+ /** The JSON backlog — stable, ranked, consumable by `dataset mine`. */
131
+ export declare function renderCoverageJson(report: CoverageReport): string;
132
+ /** The plain-text report — the default terminal output. */
133
+ export declare function renderCoverageText(report: CoverageReport): string;
134
+ /** Dependency-free HTML report, styled to mirror eval-report / graders-suggest. */
135
+ export declare function renderCoverageHtml(report: CoverageReport): string;
136
+ /** Dispatch the requested format. */
137
+ export declare function renderCoverage(report: CoverageReport, format: CoverageFormat): string;
138
+ /** Suggested output filename for a `-o <dir>` write (text/html/json). */
139
+ export declare function coverageFileName(format: CoverageFormat): string;
140
+ export type { SessionTurn };
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Item 6 — `crewhaus eval coverage`: detect agent behaviors that PRODUCTION
3
+ * exercises but the eval dataset never does. Answers "what is the agent doing
4
+ * in the wild that my eval is blind to?" so a user can close the gaps before
5
+ * they ship a regression.
6
+ *
7
+ * Two distributions are built and intersected, both DETERMINISTICALLY (no
8
+ * model call — the same clustering `graders-suggest` uses):
9
+ *
10
+ * - PRODUCTION behavior, from the cwd spec's session JSONLs
11
+ * (`.crewhaus/sessions/*.jsonl`): tool/MCP call frequencies (parsed from
12
+ * `assistant_message` `tool_use` blocks — MCP tools carry the `mcp__`
13
+ * prefix), tool sequence bigrams, compaction-fired frequency, and clustered
14
+ * user-input themes (via `deriveTurns` + token clustering).
15
+ * - EVAL coverage, from what the dataset EXERCISES: each Sample's
16
+ * `expected_tools` (file or `registry:` ref) plus the tools actually used
17
+ * in the most recent eval run's per-sample `events.jsonl` (located via the
18
+ * item-3 run-history index; the eval events use the `tool_call_end` trace
19
+ * shape, distinct from the production `assistant_message` shape).
20
+ *
21
+ * The report is a ranked backlog of gaps — tools/bigrams/behaviors present in
22
+ * prod but absent from eval — rendered as text, dependency-free HTML (mirrors
23
+ * eval-report / graders-suggest style), or JSON. The JSON is a stable,
24
+ * ranked backlog `dataset mine` (item 2) can consume.
25
+ *
26
+ * Kept in a side-effect-free module (the CLI entry file runs an argv switch on
27
+ * import) mirroring `feedback.ts` / `graders-suggest.ts`; all filesystem
28
+ * access and registry/run-index resolution live in `apps/cli/src/index.ts`.
29
+ */
30
+ import { deriveTurns } from "./feedback";
31
+ import { normalizeEvidenceTokens, toolNamesFromEventsJsonl } from "./graders-suggest";
32
+ /** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
33
+ * through `die()`; tests assert on `.message` without the process exiting. */
34
+ export class EvalCoverageError extends Error {
35
+ name = "EvalCoverageError";
36
+ }
37
+ /** How many sessions feed the distribution by default (most-recent first). */
38
+ export const DEFAULT_COVERAGE_SESSIONS = 50;
39
+ /** Parse `--format`; defaults to text. */
40
+ export function parseCoverageFormat(value) {
41
+ if (value === undefined)
42
+ return "text";
43
+ if (value === "text" || value === "html" || value === "json")
44
+ return value;
45
+ throw new EvalCoverageError(`invalid --format "${value}" — expected text, html, or json`);
46
+ }
47
+ /** Parse `--sessions`: a positive integer or `all`. */
48
+ export function parseSessionsFlag(value) {
49
+ if (value === undefined)
50
+ return DEFAULT_COVERAGE_SESSIONS;
51
+ if (value.trim().toLowerCase() === "all")
52
+ return "all";
53
+ if (!/^\d+$/.test(value.trim())) {
54
+ throw new EvalCoverageError(`invalid --sessions "${value}" — expected a positive integer or "all"`);
55
+ }
56
+ const n = Number.parseInt(value, 10);
57
+ if (n < 1)
58
+ throw new EvalCoverageError(`invalid --sessions "${value}" — need at least 1`);
59
+ return n;
60
+ }
61
+ function contentBlocks(payload) {
62
+ const content = payload?.content;
63
+ return Array.isArray(content) ? content : [];
64
+ }
65
+ /** The tool names an `assistant_message` payload called, verbatim (PascalCase
66
+ * builtins, `mcp__`-prefixed MCP tools), in first-seen order. */
67
+ export function assistantToolNames(payload) {
68
+ return contentBlocks(payload)
69
+ .filter((b) => b.type === "tool_use" && typeof b.name === "string")
70
+ .map((b) => b.name);
71
+ }
72
+ /** True when a tool name is an MCP tool (verbatim `mcp__`-prefixed). */
73
+ export function isMcpTool(name) {
74
+ return name.startsWith("mcp__");
75
+ }
76
+ const BIGRAM_SEP = " ";
77
+ /** Split a bigram key back into its two tool names. */
78
+ export function splitBigram(key) {
79
+ const idx = key.indexOf(BIGRAM_SEP);
80
+ return [key.slice(0, idx), key.slice(idx + 1)];
81
+ }
82
+ /**
83
+ * Build the production behavior distribution from a set of sessions' parsed
84
+ * event arrays. Each session contributes at most once to the per-session
85
+ * counts (tool_sessions / bigram_sessions / compaction), so a chatty session
86
+ * cannot dominate the frequency ranking; raw call totals are kept separately.
87
+ * User inputs are clustered by the same deterministic token overlap
88
+ * `graders-suggest` uses.
89
+ */
90
+ export function buildProdBehavior(sessions) {
91
+ const toolSessions = new Map();
92
+ const toolCalls = new Map();
93
+ const bigramSessions = new Map();
94
+ let compactionSessions = 0;
95
+ const allInputs = [];
96
+ for (const { events } of sessions) {
97
+ const seenTools = new Set();
98
+ const seenBigrams = new Set();
99
+ // Ordered tool-call stream across the whole session (for bigrams).
100
+ const toolStream = [];
101
+ let sawCompaction = false;
102
+ for (const ev of events) {
103
+ if (ev.kind === "assistant_message") {
104
+ for (const name of assistantToolNames(ev.payload)) {
105
+ toolCalls.set(name, (toolCalls.get(name) ?? 0) + 1);
106
+ seenTools.add(name);
107
+ toolStream.push(name);
108
+ }
109
+ }
110
+ else if (ev.kind === "compaction") {
111
+ sawCompaction = true;
112
+ }
113
+ }
114
+ for (const name of seenTools)
115
+ toolSessions.set(name, (toolSessions.get(name) ?? 0) + 1);
116
+ for (let i = 0; i + 1 < toolStream.length; i += 1) {
117
+ const key = `${toolStream[i]}${BIGRAM_SEP}${toolStream[i + 1]}`;
118
+ seenBigrams.add(key);
119
+ }
120
+ for (const key of seenBigrams)
121
+ bigramSessions.set(key, (bigramSessions.get(key) ?? 0) + 1);
122
+ if (sawCompaction)
123
+ compactionSessions += 1;
124
+ // Derived user-input text for clustering.
125
+ for (const t of deriveTurns(events)) {
126
+ if (t.input.trim() !== "")
127
+ allInputs.push(t.input);
128
+ }
129
+ }
130
+ return {
131
+ sessionCount: sessions.length,
132
+ toolSessions,
133
+ toolCalls,
134
+ bigramSessions,
135
+ compactionSessions,
136
+ inputThemes: clusterInputs(allInputs),
137
+ };
138
+ }
139
+ /**
140
+ * Deterministic greedy clustering of user inputs by normalized token overlap
141
+ * — the same seed-comparison algorithm `graders-suggest.clusterFailures`
142
+ * uses, inlined here over plain strings. Inputs are visited in a stable order
143
+ * (text asc); themes come back largest-first, labelled by their most frequent
144
+ * tokens. Inputs with no clusterable tokens are dropped.
145
+ */
146
+ export function clusterInputs(inputs, threshold = 0.34) {
147
+ const sorted = [...inputs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
148
+ const clusters = [];
149
+ for (const input of sorted) {
150
+ const tokens = new Set(normalizeEvidenceTokens(input));
151
+ if (tokens.size === 0)
152
+ continue;
153
+ let bestIdx = -1;
154
+ let bestOverlap = 0;
155
+ for (let i = 0; i < clusters.length; i += 1) {
156
+ const overlap = jaccard(tokens, clusters[i].seed);
157
+ if (overlap > bestOverlap) {
158
+ bestOverlap = overlap;
159
+ bestIdx = i;
160
+ }
161
+ }
162
+ if (bestIdx >= 0 && bestOverlap >= threshold) {
163
+ const c = clusters[bestIdx];
164
+ c.texts.push(input);
165
+ c.tokenSets.push(tokens);
166
+ }
167
+ else {
168
+ clusters.push({ seed: tokens, texts: [input], tokenSets: [tokens] });
169
+ }
170
+ }
171
+ const themes = clusters.map((c) => {
172
+ const freq = new Map();
173
+ for (const set of c.tokenSets)
174
+ for (const t of set)
175
+ freq.set(t, (freq.get(t) ?? 0) + 1);
176
+ const tokens = [...freq.entries()]
177
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
178
+ .slice(0, 3)
179
+ .map(([t]) => t);
180
+ const exemplar = [...c.texts].sort((a, b) => a.length - b.length || (a < b ? -1 : 1))[0] ?? "";
181
+ return { label: tokens.join(" "), tokens, count: c.texts.length, exemplar };
182
+ });
183
+ return themes.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
184
+ }
185
+ function jaccard(a, b) {
186
+ let intersection = 0;
187
+ for (const t of a)
188
+ if (b.has(t))
189
+ intersection += 1;
190
+ const union = a.size + b.size - intersection;
191
+ return union === 0 ? 0 : intersection / union;
192
+ }
193
+ /**
194
+ * Build the eval-coverage distribution from the dataset's `expected_tools` and
195
+ * (optionally) the most recent eval run's per-sample `events.jsonl` texts.
196
+ * Both a sample's expected_tools sequence and a run's observed tool stream
197
+ * contribute their ordered bigrams, so a bigram is "exercised" if EITHER the
198
+ * dataset asserts it or a real eval run walked it.
199
+ */
200
+ export function buildEvalCoverage(samples, runEventTexts) {
201
+ const tools = new Set();
202
+ const bigrams = new Set();
203
+ let hasExpectedTools = false;
204
+ for (const s of samples) {
205
+ const seq = s.expected_tools ?? [];
206
+ if (seq.length > 0)
207
+ hasExpectedTools = true;
208
+ for (const t of seq)
209
+ tools.add(t);
210
+ for (let i = 0; i + 1 < seq.length; i += 1) {
211
+ bigrams.add(`${seq[i]}${BIGRAM_SEP}${seq[i + 1]}`);
212
+ }
213
+ }
214
+ for (const text of runEventTexts) {
215
+ const seq = toolNamesFromEventsJsonl(text);
216
+ for (const t of seq)
217
+ tools.add(t);
218
+ for (let i = 0; i + 1 < seq.length; i += 1) {
219
+ bigrams.add(`${seq[i]}${BIGRAM_SEP}${seq[i + 1]}`);
220
+ }
221
+ }
222
+ return {
223
+ toolsExercised: tools,
224
+ bigramsExercised: bigrams,
225
+ sampleCount: samples.length,
226
+ hasRunEvents: runEventTexts.length > 0,
227
+ hasExpectedTools,
228
+ };
229
+ }
230
+ const pct = (f) => `${Math.round(f * 100)}%`;
231
+ /**
232
+ * Intersect the production distribution with the eval coverage and rank the
233
+ * gaps. A tool/bigram is a gap when it appears in ≥1 production session but is
234
+ * never exercised by the dataset or a recent eval run. Compaction is a gap
235
+ * when it fired in prod but no run event stream shows it was exercised (eval
236
+ * runs don't compact, so any prod compaction is by definition uncovered —
237
+ * surfaced whenever it fired at all). Gaps are ranked by session frequency
238
+ * desc, then subject asc for stability.
239
+ */
240
+ export function computeCoverage(opts) {
241
+ const { prod, evalCov } = opts;
242
+ const gaps = [];
243
+ const n = prod.sessionCount;
244
+ const frac = (c) => (n === 0 ? 0 : c / n);
245
+ for (const [tool, sessions] of prod.toolSessions) {
246
+ if (evalCov.toolsExercised.has(tool))
247
+ continue;
248
+ const calls = prod.toolCalls.get(tool) ?? sessions;
249
+ gaps.push({
250
+ kind: isMcpTool(tool) ? "mcp-tool" : "tool",
251
+ subject: tool,
252
+ sessions,
253
+ fraction: frac(sessions),
254
+ detail: `${tool} appears in ${pct(frac(sessions))} of sessions (${sessions}/${n}, ${calls} call(s)) but 0 dataset samples exercise it`,
255
+ });
256
+ }
257
+ for (const [key, sessions] of prod.bigramSessions) {
258
+ if (evalCov.bigramsExercised.has(key))
259
+ continue;
260
+ const [a, b] = splitBigram(key);
261
+ gaps.push({
262
+ kind: "bigram",
263
+ subject: `${a} → ${b}`,
264
+ sessions,
265
+ fraction: frac(sessions),
266
+ detail: `tool sequence ${a} → ${b} occurs in ${pct(frac(sessions))} of sessions (${sessions}/${n}) but no expected_tools sequence covers it`,
267
+ });
268
+ }
269
+ if (prod.compactionSessions > 0) {
270
+ const f = frac(prod.compactionSessions);
271
+ gaps.push({
272
+ kind: "compaction",
273
+ subject: "compaction",
274
+ sessions: prod.compactionSessions,
275
+ fraction: f,
276
+ detail: `compaction fired in ${prod.compactionSessions} session(s) (${pct(f)}) but is never exercised in eval — add a long-context sample`,
277
+ });
278
+ }
279
+ gaps.sort((a, b) => b.sessions - a.sessions ||
280
+ kindRank(a.kind) - kindRank(b.kind) ||
281
+ a.subject.localeCompare(b.subject));
282
+ return {
283
+ ...(opts.specName !== undefined ? { specName: opts.specName } : {}),
284
+ sessionsScanned: n,
285
+ ...(opts.datasetName !== undefined ? { datasetName: opts.datasetName } : {}),
286
+ sampleCount: evalCov.sampleCount,
287
+ hasRunEvents: evalCov.hasRunEvents,
288
+ gaps,
289
+ inputThemes: prod.inputThemes,
290
+ };
291
+ }
292
+ function kindRank(kind) {
293
+ return kind === "mcp-tool" ? 0 : kind === "tool" ? 1 : kind === "compaction" ? 2 : 3;
294
+ }
295
+ // -------- rendering --------
296
+ /** The JSON backlog — stable, ranked, consumable by `dataset mine`. */
297
+ export function renderCoverageJson(report) {
298
+ const backlog = report.gaps.map((g) => ({
299
+ kind: g.kind,
300
+ subject: g.subject,
301
+ sessions: g.sessions,
302
+ fraction: Number(g.fraction.toFixed(4)),
303
+ detail: g.detail,
304
+ }));
305
+ return `${JSON.stringify({
306
+ spec: report.specName ?? null,
307
+ dataset: report.datasetName ?? null,
308
+ sessionsScanned: report.sessionsScanned,
309
+ sampleCount: report.sampleCount,
310
+ hasRunEvents: report.hasRunEvents,
311
+ gapCount: report.gaps.length,
312
+ backlog,
313
+ inputThemes: report.inputThemes.map((t) => ({
314
+ label: t.label,
315
+ tokens: t.tokens,
316
+ count: t.count,
317
+ exemplar: t.exemplar,
318
+ })),
319
+ }, null, 2)}\n`;
320
+ }
321
+ /** The plain-text report — the default terminal output. */
322
+ export function renderCoverageText(report) {
323
+ const lines = [];
324
+ const forSpec = report.specName !== undefined ? ` for "${report.specName}"` : "";
325
+ lines.push(`eval coverage${forSpec}: ${report.sessionsScanned} session(s) vs ${report.sampleCount} dataset sample(s)`);
326
+ if (report.datasetName !== undefined)
327
+ lines.push(` dataset: ${report.datasetName}`);
328
+ if (!report.hasRunEvents) {
329
+ lines.push(" (no recent eval run events found — coverage is dataset-expected_tools only)");
330
+ }
331
+ lines.push("");
332
+ if (report.gaps.length === 0) {
333
+ lines.push("no coverage gaps — every production tool/sequence is exercised by the eval.");
334
+ }
335
+ else {
336
+ lines.push(`${report.gaps.length} coverage gap(s), ranked by production frequency:`);
337
+ for (const g of report.gaps)
338
+ lines.push(` - [${g.kind}] ${g.detail}`);
339
+ }
340
+ if (report.inputThemes.length > 0) {
341
+ lines.push("");
342
+ lines.push(`production input themes (${report.inputThemes.length}) — check the dataset probes each:`);
343
+ for (const t of report.inputThemes.slice(0, 10)) {
344
+ lines.push(` - "${t.label}" (${t.count} input(s)) e.g. ${clip(t.exemplar, 80)}`);
345
+ }
346
+ }
347
+ return `${lines.join("\n")}\n`;
348
+ }
349
+ const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
350
+ const escapeHtml = (s) => s
351
+ .replace(/&/g, "&amp;")
352
+ .replace(/</g, "&lt;")
353
+ .replace(/>/g, "&gt;")
354
+ .replace(/"/g, "&quot;")
355
+ .replace(/'/g, "&#39;");
356
+ /** Dependency-free HTML report, styled to mirror eval-report / graders-suggest. */
357
+ export function renderCoverageHtml(report) {
358
+ const forSpec = report.specName !== undefined ? ` — ${escapeHtml(report.specName)}` : "";
359
+ const rows = report.gaps
360
+ .map((g) => ` <tr>
361
+ <td><span class="kind kind-${g.kind}">${escapeHtml(g.kind)}</span></td>
362
+ <td>${escapeHtml(g.subject)}</td>
363
+ <td data-sort="${g.sessions}">${g.sessions} (${pct(g.fraction)})</td>
364
+ <td>${escapeHtml(g.detail)}</td>
365
+ </tr>`)
366
+ .join("\n");
367
+ const themes = report.inputThemes
368
+ .slice(0, 20)
369
+ .map((t) => ` <li><strong>${escapeHtml(t.label)}</strong> — ${t.count} input(s): <code>${escapeHtml(clip(t.exemplar, 120))}</code></li>`)
370
+ .join("\n");
371
+ const gapSection = report.gaps.length === 0
372
+ ? `<p class="empty">No coverage gaps — every production tool/sequence is exercised by the eval.</p>`
373
+ : `<table data-sortable>
374
+ <thead><tr><th>kind</th><th>subject</th><th>sessions</th><th>detail</th></tr></thead>
375
+ <tbody>
376
+ ${rows}
377
+ </tbody>
378
+ </table>`;
379
+ return `<!doctype html>
380
+ <html lang="en">
381
+ <head>
382
+ <meta charset="utf-8">
383
+ <meta name="viewport" content="width=device-width, initial-scale=1">
384
+ <title>eval coverage${forSpec}</title>
385
+ <style>
386
+ :root { --bg:#0f1115; --fg:#e6e6e6; --muted:#999; --card:#1a1d23; --border:#333; --link:#61dafb; --warn:#ffb74d; --mcp:#ef5350; }
387
+ * { box-sizing:border-box; }
388
+ body { font-family: ui-sans-serif, system-ui, sans-serif; background:var(--bg); color:var(--fg); margin:0; padding:24px; line-height:1.5; }
389
+ h1 { margin:0 0 8px; } h2 { margin:24px 0 12px; }
390
+ .meta { color:var(--muted); font-size:13px; margin-bottom:16px; }
391
+ table { width:100%; border-collapse:collapse; background:var(--card); border-radius:6px; overflow:hidden; }
392
+ th,td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:14px; vertical-align:top; }
393
+ th { background:#14171c; cursor:pointer; user-select:none; } th:hover { color:var(--link); }
394
+ tr:last-child td { border-bottom:none; }
395
+ code { background:#0a0c10; padding:2px 5px; border-radius:4px; font-size:12px; }
396
+ .kind { font-weight:600; font-size:12px; text-transform:uppercase; }
397
+ .kind-mcp-tool { color:var(--mcp); } .kind-tool { color:var(--link); } .kind-bigram { color:var(--warn); } .kind-compaction { color:var(--muted); }
398
+ .empty { color:var(--muted); } ul { padding-left:20px; }
399
+ </style>
400
+ </head>
401
+ <body>
402
+ <h1>eval coverage${forSpec}</h1>
403
+ <p class="meta">${report.sessionsScanned} session(s) scanned vs ${report.sampleCount} dataset sample(s)${report.datasetName !== undefined ? ` (${escapeHtml(report.datasetName)})` : ""}${report.hasRunEvents ? "" : " — no recent eval run events; coverage is dataset-expected_tools only"}. ${report.gaps.length} gap(s).</p>
404
+ <h2>Coverage gaps</h2>
405
+ ${gapSection}
406
+ ${report.inputThemes.length > 0 ? `<h2>Production input themes</h2>\n<ul>\n${themes}\n</ul>` : ""}
407
+ <script>
408
+ (function(){
409
+ function sortTable(table,col){var tb=table.querySelector('tbody');if(!tb)return;var rows=Array.from(tb.rows);var asc=table.dataset.sortCol===String(col)?table.dataset.sortDir!=='asc':true;rows.sort(function(a,b){var va=a.cells[col].dataset.sort||a.cells[col].textContent.trim();var vb=b.cells[col].dataset.sort||b.cells[col].textContent.trim();var na=parseFloat(va),nb=parseFloat(vb);if(!isNaN(na)&&!isNaN(nb))return asc?na-nb:nb-na;return asc?va.localeCompare(vb):vb.localeCompare(va);});rows.forEach(function(r){tb.appendChild(r);});table.dataset.sortCol=String(col);table.dataset.sortDir=asc?'asc':'desc';}
410
+ document.querySelectorAll('table[data-sortable] th').forEach(function(th,i){th.addEventListener('click',function(){sortTable(th.closest('table'),i);});});
411
+ })();
412
+ </script>
413
+ </body>
414
+ </html>
415
+ `;
416
+ }
417
+ /** Dispatch the requested format. */
418
+ export function renderCoverage(report, format) {
419
+ if (format === "json")
420
+ return renderCoverageJson(report);
421
+ if (format === "html")
422
+ return renderCoverageHtml(report);
423
+ return renderCoverageText(report);
424
+ }
425
+ /** Suggested output filename for a `-o <dir>` write (text/html/json). */
426
+ export function coverageFileName(format) {
427
+ return format === "html" ? "coverage.html" : format === "json" ? "coverage.json" : "coverage.txt";
428
+ }
@@ -0,0 +1,48 @@
1
+ import type { EvalRunSummary } from "@crewhaus/eval-runner";
2
+ import { type GateThresholds, type GateVerdict } from "@crewhaus/regression-runner";
3
+ export type FinishEvalOptions = {
4
+ readonly summary: EvalRunSummary;
5
+ readonly specName: string;
6
+ /** sha256 hex of the dataset file bytes. */
7
+ readonly datasetHash: string;
8
+ /** Absolute path to the new run's output directory. */
9
+ readonly outDir: string;
10
+ /** `--gate`: map a failing gate verdict to a non-zero exit. */
11
+ readonly gateRequested: boolean;
12
+ /** `!--no-promote`: allow baseline writes (initial pin + promotion). */
13
+ readonly promote: boolean;
14
+ /** Override `.crewhaus/evals` (tests, tenant scopes). */
15
+ readonly evalsDir?: string;
16
+ /** Line sink; defaults to stdout. */
17
+ readonly write?: (line: string) => void;
18
+ };
19
+ export type FinishEvalResult = {
20
+ /** True only when `gateRequested` and the regression gate failed. */
21
+ readonly gateFailed: boolean;
22
+ readonly gateReason?: string;
23
+ };
24
+ /**
25
+ * `eval-report history|baseline show --dataset <filter>` matching. Unioned
26
+ * runs are recorded under `<primary>+regressions@vX`, so the filter matches
27
+ * a stored datasetName that equals it exactly OR continues it with a `+`
28
+ * suffix segment — `--dataset smoke` finds `smoke` and
29
+ * `smoke+regressions@v1`, but not `smoke2`.
30
+ */
31
+ export declare function datasetFilterMatches(filter: string, datasetName: string): boolean;
32
+ /**
33
+ * Strict gate between a baseline run and a new run. Wraps
34
+ * `regression-runner`'s `gate()` with `regressionThreshold: 0` (any
35
+ * pass-rate drop fails) and additionally fails on sample-level regressions
36
+ * that net out to a flat pass rate. The latency criterion is DISABLED by
37
+ * default (threshold Infinity): the documented gate is pass-rate/flip-only,
38
+ * and regression-runner's +5000ms p95 default would fail runs the docs say
39
+ * pass. Latency gating arrives with explicit CLI flags later — callers can
40
+ * already opt in via `thresholds.latencyThreshold`.
41
+ */
42
+ export declare function gateRuns(prev: EvalRunSummary, next: EvalRunSummary, thresholds?: GateThresholds): GateVerdict;
43
+ /**
44
+ * Record a completed eval run in the history index, then resolve/diff/gate
45
+ * against the pinned baseline for its (spec, dataset) key. Returns whether
46
+ * the caller should exit non-zero (gate requested + failed).
47
+ */
48
+ export declare function finishEvalRun(opts: FinishEvalOptions): Promise<FinishEvalResult>;