crewhaus 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) 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 +173 -0
  25. package/dist/dataset-mine.js +404 -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 +11664 -963
  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/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -0,0 +1,658 @@
1
+ import { gradersConfigToYaml, mergeFeedback, normalizeRating, } from "./feedback";
2
+ /** Thrown on malformed flags / unusable evidence. The CLI entry file routes
3
+ * it through `die()`; tests assert on `.message`. */
4
+ export class GradersSuggestError extends Error {
5
+ name = "GradersSuggestError";
6
+ }
7
+ /** How many recent runs (for the cwd spec) feed the suggestion by default. */
8
+ export const DEFAULT_SUGGEST_RUNS = 10;
9
+ /** Default output path for the review file. */
10
+ export const DEFAULT_SUGGESTED_GRADERS_FILE = "graders-suggested.yaml";
11
+ /** One-liner printed by `crewhaus distill` when it falls back to the floor
12
+ * grader (see {@link isFloorGraderConfig}). */
13
+ export const FLOOR_GRADER_HINT = "hint: draft sharper graders from recent eval failures and ratings with `crewhaus graders suggest`";
14
+ /** True when a synthesized config is exactly the non-empty-answer floor
15
+ * grader `synthesizeGraders` emits when the ratings carry no signal — the
16
+ * trigger for the {@link FLOOR_GRADER_HINT} suggestion hook. */
17
+ export function isFloorGraderConfig(config) {
18
+ const g = config.graders[0];
19
+ return (config.graders.length === 1 &&
20
+ g !== undefined &&
21
+ g.type === "regex" &&
22
+ g.name === "non_empty_answer");
23
+ }
24
+ /** Parse `--runs`: `last:N` / `last N` selects the N most recent indexed
25
+ * runs; anything else is a run directory path. */
26
+ export function parseRunsFlag(value) {
27
+ const m = /^last[:\s]?(\d+)$/i.exec(value.trim());
28
+ if (m !== null) {
29
+ const n = Number.parseInt(m[1], 10);
30
+ if (n < 1)
31
+ throw new GradersSuggestError(`invalid --runs "${value}" — need at least 1 run`);
32
+ return { kind: "last", n };
33
+ }
34
+ return { kind: "dir", dir: value };
35
+ }
36
+ /** Mirror of eval-runner's per-sample dir sanitizer — the key LoadedRun's
37
+ * perSample map uses. Keep in sync with run-sample.ts. */
38
+ export function sanitizeSampleId(id) {
39
+ return id.replace(/[^A-Za-z0-9_.-]/g, "_");
40
+ }
41
+ /** Unique tool names (first-seen order) from a per-sample events.jsonl —
42
+ * `tool_call_end` trace events carry the runtime tool name. Tolerant of
43
+ * torn/corrupt lines. */
44
+ export function toolNamesFromEventsJsonl(text) {
45
+ const out = [];
46
+ const seen = new Set();
47
+ for (const line of text.split("\n")) {
48
+ const trimmed = line.trim();
49
+ if (trimmed === "")
50
+ continue;
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(trimmed);
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ const ev = parsed;
59
+ if (ev.kind !== "tool_call_end" || typeof ev.toolName !== "string")
60
+ continue;
61
+ if (seen.has(ev.toolName))
62
+ continue;
63
+ seen.add(ev.toolName);
64
+ out.push(ev.toolName);
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Strip runner/judge boilerplate from a grader rationale so clustering sees
70
+ * the substance: the `[name: ✗]` combinator markers, the `judge=N (need
71
+ * ≥M):` prefix. Infra noise ("grader threw: …", "agent invocation error:
72
+ * …") returns "" — an outage is not a failure theme.
73
+ */
74
+ export function cleanRationale(rationale) {
75
+ let s = rationale.trim();
76
+ if (s.startsWith("grader threw:") || s.startsWith("agent invocation error"))
77
+ return "";
78
+ s = s.replace(/\[[^\]]*: [✓✗]\]/g, " ");
79
+ s = s.replace(/judge=\d+(?:\.\d+)? \(need ≥\d+(?:\.\d+)?\):/g, " ");
80
+ return s.replace(/\s+/g, " ").trim();
81
+ }
82
+ /** Defensive parse of a raw grades.json for judge criterionScores (not part
83
+ * of GradeResult today — see the module doc). Criteria scored ≤ 2 of 5
84
+ * become failure evidence in their own right. */
85
+ export function criterionEvidence(gradesText, sampleId, runId) {
86
+ if (gradesText.trim() === "")
87
+ return [];
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(gradesText);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ const perGrader = parsed.perGrader;
96
+ if (!Array.isArray(perGrader))
97
+ return [];
98
+ const out = [];
99
+ for (const entry of perGrader) {
100
+ const e = entry;
101
+ if (typeof e.name !== "string" || typeof e.criterionScores !== "object")
102
+ continue;
103
+ if (e.criterionScores === null)
104
+ continue;
105
+ const scores = Object.entries(e.criterionScores)
106
+ .filter((kv) => typeof kv[1] === "number" && kv[1] <= 2)
107
+ .sort((a, b) => a[0].localeCompare(b[0]));
108
+ for (const [criterion, score] of scores) {
109
+ out.push({
110
+ sampleId,
111
+ runId,
112
+ source: `${e.name}:criterion`,
113
+ text: `judge criterion ${criterion} scored ${score}/5`,
114
+ });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Extract failure evidence + pass exemplars from one loaded eval run.
121
+ * Samples are visited in sampleId order (determinism), errored/grader-threw
122
+ * samples are skipped wholesale (infra noise, not a failure theme — the
123
+ * same posture as triage's pin guards).
124
+ */
125
+ export function evidenceFromRun(run) {
126
+ const runId = run.summary.runId;
127
+ const failures = [];
128
+ const passes = [];
129
+ const samples = [...run.summary.samples].sort((a, b) => a.sampleId.localeCompare(b.sampleId));
130
+ for (const s of samples) {
131
+ if (s.error !== undefined || s.graderError !== undefined)
132
+ continue;
133
+ const perSample = run.perSample[sanitizeSampleId(s.sampleId)];
134
+ if (s.grades.overall.passed) {
135
+ passes.push({
136
+ sampleId: s.sampleId,
137
+ runId,
138
+ output: s.agentOutput,
139
+ toolNames: toolNamesFromEventsJsonl(perSample?.events ?? ""),
140
+ });
141
+ continue;
142
+ }
143
+ for (const g of s.grades.perGrader) {
144
+ if (g.passed)
145
+ continue;
146
+ const text = cleanRationale(g.rationale);
147
+ if (text === "")
148
+ continue;
149
+ failures.push({ sampleId: s.sampleId, runId, source: g.name, text, output: s.agentOutput });
150
+ }
151
+ failures.push(...criterionEvidence(perSample?.grades ?? "", s.sampleId, runId));
152
+ }
153
+ return { failures, passes };
154
+ }
155
+ /**
156
+ * Fold user ratings into the evidence: down-rated comments become failure
157
+ * evidence, up-rated turns become pass exemplars (their outputs + observed
158
+ * tool names). Positive comments are returned separately — they season the
159
+ * llm_judge rubric prompt, not the clustering.
160
+ */
161
+ export function evidenceFromFeedback(turns, records, minScore) {
162
+ const turnByKey = new Map();
163
+ for (const t of turns)
164
+ turnByKey.set(`${t.sessionId}#${t.turnNumber}`, t);
165
+ const failures = [];
166
+ const passes = [];
167
+ const positiveComments = [];
168
+ for (const fb of mergeFeedback(records)) {
169
+ const turn = turnByKey.get(`${fb.sessionId}#${fb.turnNumber}`);
170
+ if (turn === undefined)
171
+ continue;
172
+ const score = normalizeRating(fb);
173
+ const isPositive = (score !== undefined && score >= minScore) || fb.correction !== undefined;
174
+ const sampleId = `${fb.sessionId}_t${fb.turnNumber}`;
175
+ if (isPositive) {
176
+ if (turn.output.trim() !== "") {
177
+ passes.push({
178
+ sampleId,
179
+ runId: "feedback",
180
+ output: turn.output,
181
+ toolNames: turn.toolNames,
182
+ });
183
+ }
184
+ if (fb.comment !== undefined && fb.comment.trim() !== "") {
185
+ positiveComments.push(fb.comment);
186
+ }
187
+ }
188
+ else if (fb.comment !== undefined && fb.comment.trim() !== "") {
189
+ failures.push({
190
+ sampleId,
191
+ runId: "feedback",
192
+ source: "user_feedback",
193
+ text: fb.comment,
194
+ output: turn.output,
195
+ });
196
+ }
197
+ }
198
+ return { failures, passes, positiveComments };
199
+ }
200
+ // -------- deterministic clustering --------
201
+ /** Stopwords for the theme tokens: common English glue plus grader-rationale
202
+ * boilerplate that would otherwise weld unrelated failures together. */
203
+ const THEME_STOPWORDS = new Set([
204
+ "the",
205
+ "and",
206
+ "that",
207
+ "this",
208
+ "with",
209
+ "from",
210
+ "your",
211
+ "have",
212
+ "will",
213
+ "here",
214
+ "there",
215
+ "what",
216
+ "when",
217
+ "which",
218
+ "would",
219
+ "could",
220
+ "should",
221
+ "about",
222
+ "into",
223
+ "then",
224
+ "than",
225
+ "them",
226
+ "they",
227
+ "you",
228
+ "for",
229
+ "are",
230
+ "was",
231
+ "were",
232
+ "its",
233
+ "not",
234
+ "but",
235
+ "does",
236
+ // grader-rationale boilerplate
237
+ "output",
238
+ "expected",
239
+ "got",
240
+ "saw",
241
+ ]);
242
+ /** Normalized token list for one evidence text: lowercase, alphanumeric,
243
+ * length ≥ 3, non-stopword, unique, in first-seen order. */
244
+ export function normalizeEvidenceTokens(text) {
245
+ const out = [];
246
+ const seen = new Set();
247
+ for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
248
+ if (raw.length < 3 || THEME_STOPWORDS.has(raw) || seen.has(raw))
249
+ continue;
250
+ seen.add(raw);
251
+ out.push(raw);
252
+ }
253
+ return out;
254
+ }
255
+ function jaccard(a, b) {
256
+ let intersection = 0;
257
+ for (const t of a)
258
+ if (b.has(t))
259
+ intersection += 1;
260
+ const union = a.size + b.size - intersection;
261
+ return union === 0 ? 0 : intersection / union;
262
+ }
263
+ /**
264
+ * Deterministic greedy clustering by normalized token overlap — no model
265
+ * call. Items are visited in a stable sort order (runId, sampleId, source,
266
+ * text) and joined to the best existing cluster when the Jaccard overlap
267
+ * with its SEED item's tokens reaches `threshold` (seed comparison keeps a
268
+ * cluster from drifting as it grows); otherwise they seed a new cluster.
269
+ * Themes come back largest-first, labelled by their most frequent tokens.
270
+ */
271
+ export function clusterFailures(items, opts = {}) {
272
+ const threshold = opts.threshold ?? 0.34;
273
+ const sorted = [...items].sort((a, b) => a.runId.localeCompare(b.runId) ||
274
+ a.sampleId.localeCompare(b.sampleId) ||
275
+ a.source.localeCompare(b.source) ||
276
+ a.text.localeCompare(b.text));
277
+ const clusters = [];
278
+ for (const item of sorted) {
279
+ const tokens = new Set(normalizeEvidenceTokens(item.text));
280
+ if (tokens.size === 0)
281
+ continue;
282
+ let bestIdx = -1;
283
+ let bestOverlap = 0;
284
+ for (let i = 0; i < clusters.length; i += 1) {
285
+ const overlap = jaccard(tokens, clusters[i].seed);
286
+ if (overlap > bestOverlap) {
287
+ bestOverlap = overlap;
288
+ bestIdx = i;
289
+ }
290
+ }
291
+ if (bestIdx >= 0 && bestOverlap >= threshold) {
292
+ const c = clusters[bestIdx];
293
+ c.items.push(item);
294
+ c.tokenSets.push(tokens);
295
+ }
296
+ else {
297
+ clusters.push({ seed: tokens, items: [item], tokenSets: [tokens] });
298
+ }
299
+ }
300
+ const themes = clusters.map((c) => {
301
+ const freq = new Map();
302
+ for (const set of c.tokenSets) {
303
+ for (const t of set)
304
+ freq.set(t, (freq.get(t) ?? 0) + 1);
305
+ }
306
+ const tokens = [...freq.entries()]
307
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
308
+ .slice(0, 3)
309
+ .map(([t]) => t);
310
+ return {
311
+ label: tokens.join(" "),
312
+ tokens,
313
+ items: c.items,
314
+ sampleIds: [...new Set(c.items.map((i) => i.sampleId))],
315
+ runIds: [...new Set(c.items.map((i) => i.runId))],
316
+ sources: [...new Set(c.items.map((i) => i.source))].sort(),
317
+ };
318
+ });
319
+ return themes.sort((a, b) => b.items.length - a.items.length || a.label.localeCompare(b.label));
320
+ }
321
+ const slugify = (s) => s
322
+ .toLowerCase()
323
+ .replace(/[^a-z0-9]+/g, "_")
324
+ .replace(/^_+|_+$/g, "")
325
+ .slice(0, 40);
326
+ function themeEvidenceLine(theme) {
327
+ return (`evidence: ${theme.items.length} failing rationale(s) on ${theme.sampleIds.length} sample(s) ` +
328
+ `across ${theme.runIds.length} run(s)/source(s) [${theme.sources.join(", ")}]`);
329
+ }
330
+ /** Tools present in EVERY tool-using pass exemplar (falling back to the
331
+ * single most common tool) — the same selection `synthesizeGraders` makes. */
332
+ function sharedPassTools(passes) {
333
+ const counts = new Map();
334
+ let toolUsing = 0;
335
+ for (const p of passes) {
336
+ const uniq = [...new Set(p.toolNames)];
337
+ if (uniq.length > 0)
338
+ toolUsing += 1;
339
+ for (const t of uniq)
340
+ counts.set(t, (counts.get(t) ?? 0) + 1);
341
+ }
342
+ if (toolUsing === 0 || counts.size === 0)
343
+ return [];
344
+ const inEvery = [...counts.entries()]
345
+ .filter(([, c]) => c === toolUsing)
346
+ .map(([t]) => t)
347
+ .sort();
348
+ if (inEvery.length > 0)
349
+ return inEvery;
350
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
351
+ return top !== undefined ? [top[0]] : [];
352
+ }
353
+ function themeMentionsTools(theme, toolNames) {
354
+ if (theme.tokens.includes("tool") || theme.tokens.includes("tools"))
355
+ return true;
356
+ const lowered = new Set(toolNames.map((t) => t.toLowerCase()));
357
+ return theme.items.some((i) => normalizeEvidenceTokens(i.text).some((tok) => lowered.has(tok)));
358
+ }
359
+ /** All pass outputs parse as JSON objects sharing ≥1 top-level key → that
360
+ * key (alphabetically first among common keys); undefined otherwise. */
361
+ function commonJsonKey(passes) {
362
+ if (passes.length === 0)
363
+ return undefined;
364
+ let common;
365
+ for (const p of passes) {
366
+ let parsed;
367
+ try {
368
+ parsed = JSON.parse(p.output);
369
+ }
370
+ catch {
371
+ return undefined;
372
+ }
373
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
374
+ return undefined;
375
+ const keys = new Set(Object.keys(parsed));
376
+ common = common === undefined ? keys : new Set([...common].filter((k) => keys.has(k)));
377
+ }
378
+ const sorted = [...(common ?? [])].sort();
379
+ return sorted[0];
380
+ }
381
+ /** Longest common prefix of the trimmed pass outputs (≥ 2 passes, ≥ 4
382
+ * chars, must contain a non-space char); undefined otherwise. */
383
+ function commonOutputPrefix(passes) {
384
+ if (passes.length < 2)
385
+ return undefined;
386
+ const outputs = passes.map((p) => p.output.trim());
387
+ if (outputs.some((o) => o === ""))
388
+ return undefined;
389
+ let prefix = outputs[0];
390
+ for (const o of outputs.slice(1)) {
391
+ let i = 0;
392
+ while (i < prefix.length && i < o.length && prefix[i] === o[i])
393
+ i += 1;
394
+ prefix = prefix.slice(0, i);
395
+ if (prefix === "")
396
+ return undefined;
397
+ }
398
+ prefix = prefix.trimEnd();
399
+ if (prefix.length < 4 || prefix.trim() === "")
400
+ return undefined;
401
+ return prefix.slice(0, 60);
402
+ }
403
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
404
+ /**
405
+ * The most discriminative token: appears in ≥ half of the up-rated outputs
406
+ * and is ranked by how much rarer it is in the failing outputs (pass
407
+ * document-frequency minus fail document-frequency, ties alphabetical).
408
+ */
409
+ function discriminativeToken(passes, failOutputs) {
410
+ const nonEmpty = passes.map((p) => p.output).filter((o) => o.trim() !== "");
411
+ if (nonEmpty.length === 0)
412
+ return undefined;
413
+ const passFreq = new Map();
414
+ for (const output of nonEmpty) {
415
+ for (const t of new Set(normalizeEvidenceTokens(output))) {
416
+ passFreq.set(t, (passFreq.get(t) ?? 0) + 1);
417
+ }
418
+ }
419
+ const failFreq = new Map();
420
+ const fails = failOutputs.filter((o) => o.trim() !== "");
421
+ for (const output of fails) {
422
+ for (const t of new Set(normalizeEvidenceTokens(output))) {
423
+ failFreq.set(t, (failFreq.get(t) ?? 0) + 1);
424
+ }
425
+ }
426
+ const threshold = Math.ceil(nonEmpty.length / 2);
427
+ const ranked = [...passFreq.entries()]
428
+ .filter(([t, c]) => t.length >= 4 && c >= threshold)
429
+ .map(([t, c]) => ({
430
+ token: t,
431
+ score: c / nonEmpty.length - (fails.length === 0 ? 0 : (failFreq.get(t) ?? 0) / fails.length),
432
+ }))
433
+ .sort((a, b) => b.score - a.score || a.token.localeCompare(b.token));
434
+ const best = ranked[0];
435
+ // A token every failing output also carries discriminates nothing.
436
+ return best !== undefined && best.score > 0 ? best.token : undefined;
437
+ }
438
+ /**
439
+ * Draft one deterministic grader per failure theme from the observed
440
+ * up-rated outputs, in priority order: tool_call_sequence (theme mentions
441
+ * tools + passes share tools) → json_path (all pass outputs are JSON
442
+ * objects with a common key) → regex (shared output prefix) → contains
443
+ * (discriminative pass token). Themes with no deterministic signal come
444
+ * back in `undrafted` so the review file can note them.
445
+ */
446
+ export function draftGradersForThemes(themes, passes) {
447
+ const suggestions = [];
448
+ const undrafted = [];
449
+ const usedNames = new Set();
450
+ const uniqueName = (base) => {
451
+ let name = base;
452
+ let i = 2;
453
+ while (usedNames.has(name)) {
454
+ name = `${base}_${i}`;
455
+ i += 1;
456
+ }
457
+ usedNames.add(name);
458
+ return name;
459
+ };
460
+ const passTools = sharedPassTools(passes);
461
+ const jsonKey = commonJsonKey(passes);
462
+ const prefix = commonOutputPrefix(passes);
463
+ for (const theme of themes) {
464
+ const rawSlug = slugify(theme.label);
465
+ const slug = rawSlug === "" ? "theme" : rawSlug;
466
+ const failOutputs = theme.items
467
+ .map((i) => i.output)
468
+ .filter((o) => o !== undefined);
469
+ if (passTools.length > 0 && themeMentionsTools(theme, passTools)) {
470
+ suggestions.push({
471
+ spec: {
472
+ name: uniqueName(`suggested_tools_${slug}`),
473
+ type: "tool_call_sequence",
474
+ expected: passTools,
475
+ mode: "set",
476
+ },
477
+ evidence: [
478
+ themeEvidenceLine(theme),
479
+ `drafted from ${passes.length} up-rated output(s): every tool-using one called [${passTools.join(", ")}]`,
480
+ ],
481
+ });
482
+ continue;
483
+ }
484
+ if (jsonKey !== undefined) {
485
+ const path = /^[A-Za-z_][A-Za-z0-9_]*$/.test(jsonKey)
486
+ ? `$.${jsonKey}`
487
+ : `$[${JSON.stringify(jsonKey)}]`;
488
+ suggestions.push({
489
+ spec: { name: uniqueName(`suggested_json_${slug}`), type: "json_path", path },
490
+ evidence: [
491
+ themeEvidenceLine(theme),
492
+ `drafted from ${passes.length} up-rated output(s): all are JSON objects carrying key "${jsonKey}"`,
493
+ ],
494
+ });
495
+ continue;
496
+ }
497
+ if (prefix !== undefined) {
498
+ suggestions.push({
499
+ spec: {
500
+ name: uniqueName(`suggested_format_${slug}`),
501
+ type: "regex",
502
+ pattern: `^${escapeRegex(prefix)}`,
503
+ },
504
+ evidence: [
505
+ themeEvidenceLine(theme),
506
+ `drafted from ${passes.length} up-rated output(s): all start with "${prefix}"`,
507
+ ],
508
+ });
509
+ continue;
510
+ }
511
+ const token = discriminativeToken(passes, failOutputs);
512
+ if (token !== undefined) {
513
+ suggestions.push({
514
+ spec: {
515
+ name: uniqueName(`suggested_contains_${slug}`),
516
+ type: "contains",
517
+ substring: token,
518
+ case_insensitive: true,
519
+ },
520
+ evidence: [
521
+ themeEvidenceLine(theme),
522
+ `drafted from ${passes.length} up-rated output(s): "${token}" is common in up-rated outputs and rare in failing ones`,
523
+ ],
524
+ });
525
+ continue;
526
+ }
527
+ undrafted.push(theme);
528
+ }
529
+ return { suggestions, undrafted };
530
+ }
531
+ // -------- model-drafted llm_judge rubric (pure halves) --------
532
+ /** System block for the one-shot rubric drafting call. */
533
+ export const RUBRIC_SUGGESTION_SYSTEM = `You design eval rubrics for AI agent harnesses. You will receive real GOOD exemplars (outputs users or graders rated up), real BAD evidence (grader rationales / user complaints), and the clustered failure themes. Write one llm_judge rubric criterion that separates the good from the bad.
534
+
535
+ Hard rules:
536
+ - Output exactly one JSON object: {"name": "...", "description": "...", "anchors": {"1": "...", "2": "...", "3": "...", "4": "...", "5": "..."}, "passing_score": 3}. No text outside the JSON.
537
+ - "name" is a short snake_case identifier.
538
+ - The five anchors must be concrete, observable behaviors grounded in the exemplars — 1 is the worst observed failure mode, 5 is the best observed behavior.
539
+ - Do not quote any exemplar verbatim for more than a short phrase.`;
540
+ const MAX_EXEMPLARS_IN_PROMPT = 4;
541
+ const MAX_EXEMPLAR_CHARS = 600;
542
+ const clipExemplar = (s) => s.length > MAX_EXEMPLAR_CHARS ? `${s.slice(0, MAX_EXEMPLAR_CHARS)}…` : s;
543
+ /** The user message for the rubric call: clipped good/bad exemplars + the
544
+ * deterministic theme labels. */
545
+ export function buildRubricSuggestionPrompt(goodOutputs, badEvidence, themes) {
546
+ const good = goodOutputs
547
+ .filter((o) => o.trim() !== "")
548
+ .slice(0, MAX_EXEMPLARS_IN_PROMPT)
549
+ .map((o, i) => `GOOD ${i + 1}:\n${clipExemplar(o)}`);
550
+ const bad = badEvidence
551
+ .slice(0, MAX_EXEMPLARS_IN_PROMPT)
552
+ .map((e, i) => `BAD ${i + 1} (${e.source}): ${clipExemplar(e.text)}`);
553
+ const themeLines = themes.map((t) => `- ${t.label} (${t.items.length} rationale(s))`);
554
+ return [
555
+ "FAILURE THEMES:",
556
+ ...(themeLines.length > 0 ? themeLines : ["(none clustered)"]),
557
+ "",
558
+ ...(good.length > 0 ? good : ["(no up-rated exemplars)"]),
559
+ "",
560
+ ...(bad.length > 0 ? bad : ["(no failure evidence)"]),
561
+ "",
562
+ "Write the rubric JSON now.",
563
+ ].join("\n");
564
+ }
565
+ const ANCHOR_KEYS = ["1", "2", "3", "4", "5"];
566
+ /**
567
+ * Tolerant parse of the rubric response into a complete single-criterion
568
+ * `llm_judge` GraderSpecObject (all five anchors required). Returns
569
+ * undefined on ANY shape failure — the caller keeps the deterministic
570
+ * suggestions and notes the skipped rubric.
571
+ */
572
+ export function parseRubricSuggestion(raw, model) {
573
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
574
+ if (jsonMatch === null)
575
+ return undefined;
576
+ let parsed;
577
+ try {
578
+ parsed = JSON.parse(jsonMatch[0]);
579
+ }
580
+ catch {
581
+ return undefined;
582
+ }
583
+ const r = parsed;
584
+ if (typeof r.description !== "string" || r.description.trim() === "")
585
+ return undefined;
586
+ if (typeof r.anchors !== "object" || r.anchors === null)
587
+ return undefined;
588
+ const anchorsIn = r.anchors;
589
+ const anchors = {};
590
+ for (const k of ANCHOR_KEYS) {
591
+ const v = anchorsIn[k];
592
+ if (typeof v !== "string" || v.trim() === "")
593
+ return undefined;
594
+ anchors[k] = v;
595
+ }
596
+ const rawName = typeof r.name === "string" ? slugify(r.name) : "";
597
+ const name = rawName === "" ? "suggested_judge_rubric" : rawName;
598
+ const passing = typeof r.passing_score === "number" && r.passing_score >= 1 && r.passing_score <= 5
599
+ ? r.passing_score
600
+ : 3;
601
+ return {
602
+ name,
603
+ type: "llm_judge",
604
+ rubric: {
605
+ criteria: [
606
+ {
607
+ name,
608
+ description: r.description,
609
+ anchors: anchors,
610
+ },
611
+ ],
612
+ passing_score: passing,
613
+ },
614
+ ...(model !== undefined ? { model } : {}),
615
+ };
616
+ }
617
+ /**
618
+ * Render the review file: a hard-AND-aware header, then a valid graders.yaml
619
+ * (parseable by `parseGradersConfig`, so a reviewed grader can be copied —
620
+ * or the file trimmed to one grader and used directly), with each grader
621
+ * preceded by a `# evidence:` comment citing what it was drafted from.
622
+ */
623
+ export function renderSuggestedGradersYaml(suggestions, opts) {
624
+ if (suggestions.length === 0) {
625
+ throw new GradersSuggestError("no graders to render — nothing was drafted");
626
+ }
627
+ const forSpec = opts.specName !== undefined ? ` for spec "${opts.specName}"` : "";
628
+ const feedbackNote = opts.feedbackCount > 0 ? ` + ${opts.feedbackCount} user feedback comment(s)` : "";
629
+ const judgeCount = suggestions.filter((s) => s.spec.type === "llm_judge").length;
630
+ const header = [
631
+ `# Drafted by \`crewhaus graders suggest\`${forSpec} from ${opts.failureCount} failure rationale(s)`,
632
+ `# across ${opts.runsSeen} eval run(s)${feedbackNote}. REVIEW FILE — never applied automatically.`,
633
+ "#",
634
+ "# GOTCHA — stacking graders hard-ANDs their scores: `crewhaus eval` combines every",
635
+ "# grader in a graders.yaml with eval-grader's `all(...)` (overall passes only when",
636
+ "# EVERY grader passes; overall score is the minimum). Do NOT adopt this whole file.",
637
+ `# Adopt ONE grader into eval/graders.yaml${judgeCount > 0 ? " — the llm_judge rubric is the broadest single choice" : ""};`,
638
+ "# only stack additional graders you genuinely want ALL to pass on every sample.",
639
+ ];
640
+ if (opts.undraftedLabels.length > 0) {
641
+ header.push("#", `# ${opts.undraftedLabels.length} theme(s) had no deterministic signal in the up-rated outputs`, `# (${opts.undraftedLabels.join("; ")}) — consider an llm_judge rubric for those.`);
642
+ }
643
+ const yaml = gradersConfigToYaml({ graders: suggestions.map((s) => s.spec) }, header);
644
+ const lines = yaml.split("\n");
645
+ const out = [];
646
+ let graderIdx = 0;
647
+ for (const line of lines) {
648
+ if (line.startsWith(" - name:")) {
649
+ const suggestion = suggestions[graderIdx];
650
+ graderIdx += 1;
651
+ for (const evidence of suggestion?.evidence ?? []) {
652
+ out.push(` # ${evidence}`);
653
+ }
654
+ }
655
+ out.push(line);
656
+ }
657
+ return out.join("\n");
658
+ }