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,170 @@
1
+ /**
2
+ * Item 18 — `crewhaus tools` namespace: builtin discovery + usage audit.
3
+ * The pure, side-effect-free half of the tool advisor. Three sub-commands:
4
+ *
5
+ * list — every builtin's name/description/scope/ioCapability/
6
+ * readOnly/destructive, from the RegisteredTool metadata
7
+ * the runtime already carries.
8
+ * suggest <spec> — rank builtins against `agent.instructions` by a
9
+ * deterministic keyword match (no model — the tool
10
+ * implication is the same shape scaffold-evals uses).
11
+ * audit — mine `tool_stats` + `tool_use` events across sessions
12
+ * to propose (a) removing tools never called, (b) flagging
13
+ * chronically failing tools, (c) learned read-only
14
+ * candidates (many clean calls). ADVICE-ONLY: `tools:` is
15
+ * not in OPTIMIZABLE_PATHS, so every edit is a human-review
16
+ * suggestion, never an eval-gated auto-apply.
17
+ *
18
+ * Everything here is pure so it is unit-testable; all filesystem access
19
+ * (loadToolMap, session reads, spec reads) lives in `apps/cli/src/index.ts`,
20
+ * mirroring `advise-rules.ts`.
21
+ *
22
+ * Casing bridge (load-bearing): a spec's `tools:` list uses the camelCase
23
+ * BUILTIN_TOOL_MAP keys (`read`, `webFetch`), but a session log's
24
+ * `tool_stats.toolName` / `tool_use.name` carry the RegisteredTool's
25
+ * PascalCase `.name` (`Read`, `WebFetch`). The audit correlates the two
26
+ * through the tool map (spec key → `.name`), so a tool named in the spec is
27
+ * matched to its runtime call stats regardless of the two casings.
28
+ */
29
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
30
+ import type { SessionEvents } from "./advise-rules";
31
+ /** One row of `tools list`, projected from a RegisteredTool. */
32
+ export type ToolListRow = {
33
+ /** The camelCase spec/map key (what a user writes in `tools:`). */
34
+ readonly key: string;
35
+ /** The RegisteredTool's PascalCase `.name` (what session logs record). */
36
+ readonly name: string;
37
+ readonly description: string;
38
+ readonly readOnly: boolean;
39
+ readonly destructive: boolean;
40
+ readonly scope: string;
41
+ readonly ioCapability?: string;
42
+ readonly requiresSandbox: boolean;
43
+ };
44
+ /** Project a tool map (key → RegisteredTool) into sorted list rows. */
45
+ export declare function buildToolList(toolMap: Readonly<Record<string, RegisteredTool>>): ToolListRow[];
46
+ /** One line per tool for the CLI's text mode. */
47
+ export declare function formatToolListLines(rows: ReadonlyArray<ToolListRow>): string[];
48
+ /**
49
+ * Deterministic keyword → tool implication. Each tool key maps to the
50
+ * lowercased keywords whose presence in an agent's instructions implies the
51
+ * tool is likely wanted. Kept intentionally small and obvious (no model, no
52
+ * fuzzy match) — the same posture scaffold-evals uses when it derives
53
+ * `expected_tools` from a task. Unknown/custom tools never appear here (only
54
+ * builtins are suggestible); a builtin with no keywords is never suggested.
55
+ */
56
+ export declare const TOOL_KEYWORDS: Readonly<Record<string, ReadonlyArray<string>>>;
57
+ export type ToolSuggestion = {
58
+ readonly key: string;
59
+ readonly name: string;
60
+ /** The instruction keywords that implied this tool. */
61
+ readonly matchedKeywords: ReadonlyArray<string>;
62
+ /** True when the spec already lists this tool (already-satisfied). */
63
+ readonly alreadyPresent: boolean;
64
+ };
65
+ export type ToolSuggestResult = {
66
+ /** Implied-but-missing tools, ranked by keyword-hit count then key. */
67
+ readonly missing: ReadonlyArray<ToolSuggestion>;
68
+ /** Implied tools already in the spec (reported for confidence). */
69
+ readonly present: ReadonlyArray<ToolSuggestion>;
70
+ /** Tools in the spec that no keyword implied (candidate over-grants). */
71
+ readonly unimplied: ReadonlyArray<string>;
72
+ };
73
+ /**
74
+ * Rank builtins against an agent's instructions. `specTools` is the spec's
75
+ * current `tools:` list (camelCase keys, possibly empty); `toolKeys` is the
76
+ * set of resolvable builtin keys (so a suggestion never names a tool the
77
+ * runtime can't load). Pure and deterministic.
78
+ */
79
+ export declare function suggestTools(instructions: string, specTools: ReadonlyArray<string>, toolKeys: ReadonlyArray<string>, toolMap: Readonly<Record<string, RegisteredTool>>): ToolSuggestResult;
80
+ export declare function formatSuggestLines(result: ToolSuggestResult): string[];
81
+ /** Per-tool aggregate over a session set's `tool_stats` lines, keyed by the
82
+ * PascalCase runtime `.name`. */
83
+ export type ToolUsageStats = {
84
+ calls: number;
85
+ errors: number;
86
+ totalDurationMs: number;
87
+ };
88
+ /**
89
+ * Fold session events into per-tool usage stats. Reads `tool_stats` lines
90
+ * (the durable per-call mirror); tolerant of old-vintage logs (no such
91
+ * lines → empty map). Keyed by `toolName` exactly as recorded (PascalCase).
92
+ */
93
+ export declare function buildToolUsage(sessions: ReadonlyArray<SessionEvents>): ReadonlyMap<string, ToolUsageStats>;
94
+ export type AuditThresholds = {
95
+ /** Tools with ≥ this many calls whose error rate crosses `failRate` are flagged. */
96
+ readonly failMinCalls: number;
97
+ readonly failRate: number;
98
+ /** Clean (error-free) calls at/above which a non-readOnly tool is a
99
+ * learned-readOnly candidate. */
100
+ readonly readOnlyMinCleanCalls: number;
101
+ };
102
+ export declare const DEFAULT_AUDIT_THRESHOLDS: AuditThresholds;
103
+ export type ToolAuditFinding =
104
+ /** A tool granted in the spec but never called across the mined sessions. */
105
+ {
106
+ readonly kind: "unused";
107
+ readonly key: string;
108
+ readonly name: string;
109
+ }
110
+ /** A granted tool whose error rate crosses the threshold. */
111
+ | {
112
+ readonly kind: "failing";
113
+ readonly key: string;
114
+ readonly name: string;
115
+ readonly calls: number;
116
+ readonly errors: number;
117
+ readonly rate: number;
118
+ }
119
+ /** A non-readOnly tool with many clean calls — candidate readOnly reclassify. */
120
+ | {
121
+ readonly kind: "learned-read-only";
122
+ readonly key: string;
123
+ readonly name: string;
124
+ readonly calls: number;
125
+ };
126
+ export type ToolAuditResult = {
127
+ readonly sessionIds: ReadonlyArray<string>;
128
+ readonly findings: ReadonlyArray<ToolAuditFinding>;
129
+ };
130
+ /**
131
+ * Audit a spec's granted tools against observed usage. Pure.
132
+ *
133
+ * - `specTools`: the spec's `tools:` list (camelCase keys). When the spec
134
+ * grants NO explicit list (empty), the "unused" check is skipped — an
135
+ * agent with the default toolset shouldn't be told to remove tools it
136
+ * never declared.
137
+ * - `usage`: `buildToolUsage` output (PascalCase-keyed).
138
+ * - `toolMap`: resolves a spec key → RegisteredTool (for `.name` +
139
+ * `readOnly`), bridging the two casings.
140
+ *
141
+ * Findings are advice-only (`tools:` is not optimizer-whitelisted).
142
+ */
143
+ export declare function auditTools(opts: {
144
+ readonly sessions: ReadonlyArray<SessionEvents>;
145
+ readonly specTools: ReadonlyArray<string>;
146
+ readonly usage: ReadonlyMap<string, ToolUsageStats>;
147
+ readonly toolMap: Readonly<Record<string, RegisteredTool>>;
148
+ readonly hasExplicitToolList: boolean;
149
+ readonly thresholds?: Partial<AuditThresholds>;
150
+ }): ToolAuditResult;
151
+ export declare function formatAuditLines(result: ToolAuditResult): string[];
152
+ /**
153
+ * The canonical set of built-in tool KEYS the CLI runtime can resolve at
154
+ * `crewhaus run` time — the exact key set of `loadToolMap()` in
155
+ * `apps/cli/src/index.ts`. It MUST equal `BUILTIN_TOOL_MAP`'s keys in
156
+ * `packages/target-cli/src/index.ts`: that map decides which `tools:` names
157
+ * COMPILE, this one decides which RUN, and a name in one but not the other is
158
+ * a latent break (compiles then crashes, or runs a name the emitter rejects).
159
+ * The sync test in `tools-cli.test.ts` asserts the two are equal; `loadToolMap`
160
+ * is built to cover exactly these keys.
161
+ */
162
+ export declare const CLI_RUNTIME_TOOL_KEYS: ReadonlyArray<string>;
163
+ /**
164
+ * Compare two tool-name key sets. Returns the symmetric difference so the
165
+ * sync test can report exactly which names drifted. Empty arrays ⇒ in sync.
166
+ */
167
+ export declare function diffToolMapKeys(a: ReadonlyArray<string>, b: ReadonlyArray<string>): {
168
+ readonly onlyInA: ReadonlyArray<string>;
169
+ readonly onlyInB: ReadonlyArray<string>;
170
+ };
@@ -0,0 +1,298 @@
1
+ import { payloadOf } from "./advise-rules";
2
+ /** Project a tool map (key → RegisteredTool) into sorted list rows. */
3
+ export function buildToolList(toolMap) {
4
+ return Object.entries(toolMap)
5
+ .map(([key, t]) => ({
6
+ key,
7
+ name: t.name,
8
+ description: t.description,
9
+ readOnly: t.readOnly,
10
+ destructive: t.destructive,
11
+ scope: t.scope,
12
+ ...(t.ioCapability !== undefined ? { ioCapability: t.ioCapability } : {}),
13
+ requiresSandbox: t.requiresSandbox,
14
+ }))
15
+ .sort((a, b) => a.key.localeCompare(b.key));
16
+ }
17
+ /** One line per tool for the CLI's text mode. */
18
+ export function formatToolListLines(rows) {
19
+ const lines = [];
20
+ for (const r of rows) {
21
+ const flags = [
22
+ r.readOnly ? "read-only" : undefined,
23
+ r.destructive ? "destructive" : undefined,
24
+ r.scope === "external" ? "external" : undefined,
25
+ r.ioCapability !== undefined ? `io:${r.ioCapability}` : undefined,
26
+ r.requiresSandbox ? "sandbox" : undefined,
27
+ ].filter((f) => f !== undefined);
28
+ const tags = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
29
+ lines.push(`${r.key} (${r.name})${tags}`);
30
+ lines.push(` ${r.description}`);
31
+ }
32
+ return lines;
33
+ }
34
+ // -------- tools suggest --------
35
+ /**
36
+ * Deterministic keyword → tool implication. Each tool key maps to the
37
+ * lowercased keywords whose presence in an agent's instructions implies the
38
+ * tool is likely wanted. Kept intentionally small and obvious (no model, no
39
+ * fuzzy match) — the same posture scaffold-evals uses when it derives
40
+ * `expected_tools` from a task. Unknown/custom tools never appear here (only
41
+ * builtins are suggestible); a builtin with no keywords is never suggested.
42
+ */
43
+ export const TOOL_KEYWORDS = Object.freeze({
44
+ read: ["read", "open file", "file contents", "inspect file", "cat "],
45
+ write: ["write file", "create file", "save to", "write to disk", "generate a file"],
46
+ edit: ["edit", "modify file", "patch", "replace in", "change the file"],
47
+ glob: ["glob", "find files", "list files", "match files", "file pattern"],
48
+ grep: ["grep", "search for", "search the", "find in files", "look for", "search code"],
49
+ bash: ["bash", "shell", "run command", "execute command", "terminal", "run a script"],
50
+ todoWrite: ["todo", "task list", "track tasks", "checklist"],
51
+ webFetch: ["fetch url", "fetch a url", "webpage", "web page", "http get", "download page"],
52
+ webSearch: ["web search", "search the web", "search online", "look up online", "google"],
53
+ readImage: ["image", "screenshot", "read an image", "vision", "picture"],
54
+ fetch: ["fetch", "api call", "rest api", "http request", "call an endpoint"],
55
+ python: ["python", "run python", "data analysis", "numpy", "pandas"],
56
+ javascript: ["javascript", "run javascript", "node script", "eval js"],
57
+ shell: ["shell command", "sh -c", "posix shell"],
58
+ imageGenerate: ["generate image", "create image", "dall-e", "image generation", "draw"],
59
+ ingestDocument: ["ingest", "parse document", "read document", "pdf", "docx", "extract text"],
60
+ codegraphSearch: ["codegraph", "code search", "symbol search", "find symbol"],
61
+ codegraphCallers: ["callers of", "who calls", "find callers"],
62
+ codegraphCallees: ["callees", "what does it call", "find callees"],
63
+ codegraphImpact: ["impact analysis", "blast radius", "affected by"],
64
+ });
65
+ /**
66
+ * Rank builtins against an agent's instructions. `specTools` is the spec's
67
+ * current `tools:` list (camelCase keys, possibly empty); `toolKeys` is the
68
+ * set of resolvable builtin keys (so a suggestion never names a tool the
69
+ * runtime can't load). Pure and deterministic.
70
+ */
71
+ export function suggestTools(instructions, specTools, toolKeys, toolMap) {
72
+ const haystack = instructions.toLowerCase();
73
+ const present = new Set(specTools);
74
+ const known = new Set(toolKeys);
75
+ const missing = [];
76
+ const alreadyPresent = [];
77
+ for (const [key, keywords] of Object.entries(TOOL_KEYWORDS)) {
78
+ if (!known.has(key))
79
+ continue; // never suggest an unresolvable tool
80
+ const matched = keywords.filter((kw) => haystack.includes(kw));
81
+ if (matched.length === 0)
82
+ continue;
83
+ const suggestion = {
84
+ key,
85
+ name: toolMap[key]?.name ?? key,
86
+ matchedKeywords: matched,
87
+ alreadyPresent: present.has(key),
88
+ };
89
+ (suggestion.alreadyPresent ? alreadyPresent : missing).push(suggestion);
90
+ }
91
+ const rank = (a, b) => {
92
+ const diff = b.matchedKeywords.length - a.matchedKeywords.length;
93
+ return diff !== 0 ? diff : a.key.localeCompare(b.key);
94
+ };
95
+ missing.sort(rank);
96
+ alreadyPresent.sort(rank);
97
+ // Spec tools no keyword implied — possible over-grants worth reviewing.
98
+ const impliedKeys = new Set([...missing, ...alreadyPresent].map((s) => s.key));
99
+ const unimplied = specTools.filter((t) => !impliedKeys.has(t)).sort();
100
+ return { missing, present: alreadyPresent, unimplied };
101
+ }
102
+ export function formatSuggestLines(result) {
103
+ const lines = [];
104
+ if (result.missing.length === 0) {
105
+ lines.push("no additional builtins implied by the instructions");
106
+ }
107
+ else {
108
+ lines.push("implied but not in tools:");
109
+ for (const s of result.missing) {
110
+ lines.push(` + ${s.key} (${s.name}) — matched: ${s.matchedKeywords.join(", ")}`);
111
+ }
112
+ }
113
+ if (result.present.length > 0) {
114
+ lines.push("implied and already present:");
115
+ for (const s of result.present) {
116
+ lines.push(` · ${s.key} — matched: ${s.matchedKeywords.join(", ")}`);
117
+ }
118
+ }
119
+ if (result.unimplied.length > 0) {
120
+ lines.push(`in tools: but not implied by instructions (review — possible over-grant): ${result.unimplied.join(", ")}`);
121
+ }
122
+ return lines;
123
+ }
124
+ /**
125
+ * Fold session events into per-tool usage stats. Reads `tool_stats` lines
126
+ * (the durable per-call mirror); tolerant of old-vintage logs (no such
127
+ * lines → empty map). Keyed by `toolName` exactly as recorded (PascalCase).
128
+ */
129
+ export function buildToolUsage(sessions) {
130
+ const usage = new Map();
131
+ for (const session of sessions) {
132
+ for (const obj of session.objects) {
133
+ const stats = payloadOf(obj, "tool_stats");
134
+ if (stats === undefined || typeof stats["toolName"] !== "string")
135
+ continue;
136
+ const s = usage.get(stats["toolName"]) ?? { calls: 0, errors: 0, totalDurationMs: 0 };
137
+ s.calls += 1;
138
+ if (stats["isError"] === true)
139
+ s.errors += 1;
140
+ if (typeof stats["durationMs"] === "number")
141
+ s.totalDurationMs += stats["durationMs"];
142
+ usage.set(stats["toolName"], s);
143
+ }
144
+ }
145
+ return usage;
146
+ }
147
+ export const DEFAULT_AUDIT_THRESHOLDS = Object.freeze({
148
+ failMinCalls: 5,
149
+ failRate: 0.5,
150
+ readOnlyMinCleanCalls: 10,
151
+ });
152
+ /**
153
+ * Audit a spec's granted tools against observed usage. Pure.
154
+ *
155
+ * - `specTools`: the spec's `tools:` list (camelCase keys). When the spec
156
+ * grants NO explicit list (empty), the "unused" check is skipped — an
157
+ * agent with the default toolset shouldn't be told to remove tools it
158
+ * never declared.
159
+ * - `usage`: `buildToolUsage` output (PascalCase-keyed).
160
+ * - `toolMap`: resolves a spec key → RegisteredTool (for `.name` +
161
+ * `readOnly`), bridging the two casings.
162
+ *
163
+ * Findings are advice-only (`tools:` is not optimizer-whitelisted).
164
+ */
165
+ export function auditTools(opts) {
166
+ const t = { ...DEFAULT_AUDIT_THRESHOLDS, ...(opts.thresholds ?? {}) };
167
+ const findings = [];
168
+ // Index usage by the RegisteredTool `.name` (already the usage key) and
169
+ // build a spec-key → name resolver.
170
+ const nameFor = (key) => opts.toolMap[key]?.name ?? key;
171
+ // (a) unused grants — only when the spec declared an explicit list.
172
+ if (opts.hasExplicitToolList) {
173
+ for (const key of opts.specTools) {
174
+ const name = nameFor(key);
175
+ const stats = opts.usage.get(name);
176
+ if (stats === undefined || stats.calls === 0) {
177
+ findings.push({ kind: "unused", key, name });
178
+ }
179
+ }
180
+ }
181
+ // (b) chronically failing tools — over ALL observed tools, mapped back to a
182
+ // spec key when we can (falls back to the runtime name otherwise).
183
+ const nameToKey = new Map();
184
+ for (const [key, tool] of Object.entries(opts.toolMap))
185
+ nameToKey.set(tool.name, key);
186
+ for (const [name, stats] of opts.usage) {
187
+ if (stats.calls < t.failMinCalls)
188
+ continue;
189
+ const rate = stats.errors / stats.calls;
190
+ if (rate < t.failRate)
191
+ continue;
192
+ findings.push({
193
+ kind: "failing",
194
+ key: nameToKey.get(name) ?? name,
195
+ name,
196
+ calls: stats.calls,
197
+ errors: stats.errors,
198
+ rate,
199
+ });
200
+ }
201
+ // (c) learned read-only candidates — a tool NOT declared readOnly that has
202
+ // many error-free calls. Signals it may be safe to reclassify (which
203
+ // would let the permission engine auto-allow it in auto mode).
204
+ for (const [name, stats] of opts.usage) {
205
+ if (stats.errors > 0)
206
+ continue;
207
+ if (stats.calls < t.readOnlyMinCleanCalls)
208
+ continue;
209
+ const key = nameToKey.get(name);
210
+ if (key === undefined)
211
+ continue; // an unmapped runtime name — can't propose a spec-key edit
212
+ const tool = opts.toolMap[key];
213
+ if (tool === undefined || tool.readOnly)
214
+ continue; // already read-only → nothing to learn
215
+ findings.push({ kind: "learned-read-only", key, name, calls: stats.calls });
216
+ }
217
+ // Deterministic order: unused, then failing (worst rate first), then
218
+ // learned-read-only, each alphabetized within its group.
219
+ const groupRank = (f) => f.kind === "unused" ? 0 : f.kind === "failing" ? 1 : 2;
220
+ findings.sort((a, b) => {
221
+ const g = groupRank(a) - groupRank(b);
222
+ if (g !== 0)
223
+ return g;
224
+ if (a.kind === "failing" && b.kind === "failing") {
225
+ const d = b.rate - a.rate;
226
+ if (d !== 0)
227
+ return d;
228
+ }
229
+ return a.key.localeCompare(b.key);
230
+ });
231
+ return { sessionIds: opts.sessions.map((s) => s.sessionId), findings };
232
+ }
233
+ export function formatAuditLines(result) {
234
+ const lines = [];
235
+ if (result.findings.length === 0) {
236
+ lines.push("no tool-usage findings — grants look well-matched to observed calls");
237
+ return lines;
238
+ }
239
+ for (const f of result.findings) {
240
+ switch (f.kind) {
241
+ case "unused":
242
+ lines.push(`[remove?] ${f.key} (${f.name}) — granted but never called in the mined sessions; drop it from tools: unless it's for a path these sessions didn't exercise`);
243
+ break;
244
+ case "failing":
245
+ lines.push(`[failing] ${f.key} (${f.name}) — ${f.errors}/${f.calls} calls errored (${(f.rate * 100).toFixed(0)}%); investigate inputs/backend or swap the tool`);
246
+ break;
247
+ case "learned-read-only":
248
+ lines.push(`[read-only?] ${f.key} (${f.name}) — ${f.calls} clean calls, 0 errors; if it never mutates, mark it readOnly so auto mode can auto-allow it`);
249
+ break;
250
+ }
251
+ }
252
+ return lines;
253
+ }
254
+ // -------- map-sync guard --------
255
+ /**
256
+ * The canonical set of built-in tool KEYS the CLI runtime can resolve at
257
+ * `crewhaus run` time — the exact key set of `loadToolMap()` in
258
+ * `apps/cli/src/index.ts`. It MUST equal `BUILTIN_TOOL_MAP`'s keys in
259
+ * `packages/target-cli/src/index.ts`: that map decides which `tools:` names
260
+ * COMPILE, this one decides which RUN, and a name in one but not the other is
261
+ * a latent break (compiles then crashes, or runs a name the emitter rejects).
262
+ * The sync test in `tools-cli.test.ts` asserts the two are equal; `loadToolMap`
263
+ * is built to cover exactly these keys.
264
+ */
265
+ export const CLI_RUNTIME_TOOL_KEYS = Object.freeze([
266
+ "read",
267
+ "write",
268
+ "edit",
269
+ "glob",
270
+ "grep",
271
+ "bash",
272
+ "todoWrite",
273
+ "webFetch",
274
+ "webSearch",
275
+ "readImage",
276
+ "fetch",
277
+ "python",
278
+ "javascript",
279
+ "shell",
280
+ "imageGenerate",
281
+ "ingestDocument",
282
+ "codegraphSearch",
283
+ "codegraphCallers",
284
+ "codegraphCallees",
285
+ "codegraphImpact",
286
+ ]);
287
+ /**
288
+ * Compare two tool-name key sets. Returns the symmetric difference so the
289
+ * sync test can report exactly which names drifted. Empty arrays ⇒ in sync.
290
+ */
291
+ export function diffToolMapKeys(a, b) {
292
+ const setA = new Set(a);
293
+ const setB = new Set(b);
294
+ return {
295
+ onlyInA: [...setA].filter((k) => !setB.has(k)).sort(),
296
+ onlyInB: [...setB].filter((k) => !setA.has(k)).sort(),
297
+ };
298
+ }
@@ -0,0 +1,202 @@
1
+ import type { DatasetRegistry } from "@crewhaus/dataset-registry";
2
+ import type { Sample } from "@crewhaus/eval-dataset";
3
+ import { type ArbiterAction, type FailingSample, type FailureClass } from "@crewhaus/eval-optimizer-orchestrator";
4
+ import type { SampleResult } from "@crewhaus/eval-runner";
5
+ import { type PinRegressionsResult } from "./regression-pin";
6
+ /** Fixed class order for every printed/rendered counts line. */
7
+ export declare const FAILURE_CLASS_ORDER: ReadonlyArray<FailureClass>;
8
+ /** One failing sample's arbiter verdict, joined back to its sample id. */
9
+ export type SampleVerdict = {
10
+ readonly sampleId: string;
11
+ readonly class: FailureClass;
12
+ readonly action: ArbiterAction;
13
+ readonly reason: string;
14
+ };
15
+ /** The persisted shape of `<runDir>/verdicts.json`. */
16
+ export type RunVerdicts = {
17
+ readonly source: "failure-arbiter";
18
+ readonly runId: string;
19
+ readonly arbitratedAt: string;
20
+ /** Number of failing samples arbitrated. */
21
+ readonly total: number;
22
+ readonly counts: Readonly<Record<FailureClass, number>>;
23
+ readonly dominantClass: FailureClass;
24
+ readonly recommendedAction: ArbiterAction;
25
+ readonly verdicts: ReadonlyArray<SampleVerdict>;
26
+ };
27
+ /** A failing result: errored, or graded and not passed (errors also grade
28
+ * `passed: false`, but the explicit disjunction keeps the intent legible). */
29
+ export declare function isFailing(result: SampleResult): boolean;
30
+ /** True when the failure is an ERROR — the invoker failed or a grader
31
+ * threw — rather than an honest graded verdict on the agent's output. */
32
+ export declare function isErroredResult(result: SampleResult): boolean;
33
+ /**
34
+ * Join one failing `SampleResult` back to its dataset `Sample` and shape
35
+ * the pair into the arbiter's `FailingSample` input. See the module doc
36
+ * for the `expected_output` → `reference` mapping and the (deliberately)
37
+ * absent `graderOutput`. A grader-infra failure (`graderError`, e.g. a
38
+ * judge 429 on both attempts) surfaces as `errorMessage` too, so the
39
+ * arbiter's transient-marker rule can classify it as noise. A missing
40
+ * dataset sample (should not happen — the CLI tees every sample the runner
41
+ * sees) degrades to a minimal stand-in with no reference rather than
42
+ * skipping the sample.
43
+ */
44
+ export declare function toFailingSample(result: SampleResult, sample: Sample | undefined): FailingSample;
45
+ /**
46
+ * Classify every failing sample of a completed eval run. Returns undefined
47
+ * when nothing failed — there is nothing to arbitrate, and persisting
48
+ * `aggregate([])`'s degenerate roll-up (dominant contract-ambiguity over
49
+ * zero samples) would mislead.
50
+ */
51
+ export declare function triageEvalRun(opts: {
52
+ readonly samples: ReadonlyArray<SampleResult>;
53
+ readonly samplesById: ReadonlyMap<string, Sample>;
54
+ readonly runId: string;
55
+ readonly now?: () => Date;
56
+ }): RunVerdicts | undefined;
57
+ /** `triage: 2 bug, 1 spec-gap, 3 noise, 1 contract-ambiguity` — all four
58
+ * classes always print (stable shape for log scrapers), in fixed order. */
59
+ export declare function formatTriageSummary(counts: Readonly<Record<FailureClass, number>>): string;
60
+ /** Persist verdicts as `<outDir>/verdicts.json`; returns the path. */
61
+ export declare function writeRunVerdicts(outDir: string, verdicts: RunVerdicts): string;
62
+ export type EvalTriageOptions = {
63
+ readonly samples: ReadonlyArray<SampleResult>;
64
+ /** The dataset samples the runner actually saw (see {@link tapSamples}). */
65
+ readonly samplesById: ReadonlyMap<string, Sample>;
66
+ readonly runId: string;
67
+ /** Run directory that already holds results.json; verdicts.json lands here. */
68
+ readonly outDir: string;
69
+ readonly specName: string;
70
+ /** Dataset name for pin provenance (post-union name when a suite was unioned). */
71
+ readonly sourceDataset: string;
72
+ /** Registry for the promoteRegression pin; omit to skip pinning entirely. */
73
+ readonly registry?: DatasetRegistry;
74
+ /** `!--no-regressions` — false skips the promoteRegression pin (the user
75
+ * opted out of regression-suite integration); triage itself still runs. */
76
+ readonly pin?: boolean;
77
+ /** Line sink; defaults to stdout. */
78
+ readonly write?: (line: string) => void;
79
+ /** Warning sink; defaults to stderr. */
80
+ readonly warn?: (line: string) => void;
81
+ /** Clock override for deterministic tests. */
82
+ readonly now?: () => Date;
83
+ };
84
+ /**
85
+ * The post-eval triage flow: arbitrate → persist verdicts.json → print the
86
+ * one-line summary → pin the promoteRegression (bug-class) samples. Never
87
+ * throws — each stage is isolated so e.g. an unwritable run dir still
88
+ * leaves the verdicts available for the HTML report, and a broken registry
89
+ * only skips the pin. Returns the verdicts (for the report renderer), or
90
+ * undefined when nothing failed or classification itself broke.
91
+ */
92
+ export declare function finishEvalTriage(opts: EvalTriageOptions): Promise<RunVerdicts | undefined>;
93
+ /**
94
+ * Error-rate threshold past which a run is presumed infrastructure-failed
95
+ * (credentials revoked mid-run, provider outage) even when some samples
96
+ * still failed on grades — a mass of "bug" verdicts from an outage must not
97
+ * feed the regression pin.
98
+ */
99
+ export declare const INFRA_FAILURE_ERROR_RATE = 0.5;
100
+ /**
101
+ * Run-level outage guard, mirroring eval-matrix's `cellCrashReason`: when
102
+ * every FAILING sample is an error (invoker or grader), or the run's error
103
+ * rate exceeds {@link INFRA_FAILURE_ERROR_RATE}, the run looks
104
+ * infrastructure-failed and triage must not pin anything from it. Returns
105
+ * the human-readable reason, or undefined when the run looks real.
106
+ */
107
+ export declare function runLooksInfrastructureFailed(samples: ReadonlyArray<SampleResult>): string | undefined;
108
+ /** True when an arbiter action asks for regression promotion. */
109
+ export declare function actionPromotesRegression(action: ArbiterAction): boolean;
110
+ /**
111
+ * Pin every sample whose verdict action carries `promoteRegression: true`
112
+ * into the per-spec regression suite, tagging the pin's provenance with
113
+ * `source: "failure-arbiter"` (see regression-pin's metadata.regression_pin).
114
+ * Samples the dataset tee somehow missed are skipped — a pin must append
115
+ * the sample as it lives in the source dataset, or not at all.
116
+ *
117
+ * Two candidate filters (see the module doc):
118
+ * - `datasetMemberIds` — samples the run's own dataset already contains are
119
+ * never pinned: zero coverage gain, and the resulting suite-version churn
120
+ * is what armed the fail→pin→retry gate-disarm loop.
121
+ * - `erroredIds` — samples whose failure is an ERROR are never pinned: an
122
+ * outage (e.g. "401 invalid x-api-key", which the arbiter's default rule
123
+ * labels "bug") is not a regression to guard.
124
+ */
125
+ export declare function promoteArbiterSamples(opts: {
126
+ readonly registry: DatasetRegistry;
127
+ readonly specName: string;
128
+ readonly verdicts: RunVerdicts;
129
+ readonly samplesById: ReadonlyMap<string, Sample>;
130
+ readonly sourceDataset: string;
131
+ readonly runId: string;
132
+ /** Ids already in the run's dataset — never pinned (zero coverage gain). */
133
+ readonly datasetMemberIds?: ReadonlySet<string>;
134
+ /** Ids whose failure is an ERROR (invoker/grader) — never pinned. */
135
+ readonly erroredIds?: ReadonlySet<string>;
136
+ /** Warning sink, forwarded to `pinRecoveredSamples`; defaults to stderr. */
137
+ readonly warn?: (line: string) => void;
138
+ readonly now?: () => Date;
139
+ }): Promise<PinRegressionsResult>;
140
+ /** The exclusion policy for the mutator's failure signal: noise (flaky
141
+ * infra) and contract-ambiguity (bad gold) waste mutation budget; bug and
142
+ * spec-gap are real prompt-fixable signal and stay. NOTE: a
143
+ * contract-ambiguity verdict is only actually excluded when it is backed
144
+ * by structured grader evidence — see {@link hasStructuredAmbiguityEvidence}
145
+ * and the module doc (the no-reference heuristic fires for MOST failures
146
+ * on judge-graded datasets, and excluding those would starve the mutator). */
147
+ export declare function isExcludedClass(cls: FailureClass): boolean;
148
+ /**
149
+ * True when a contract-ambiguity verdict is backed by the grader's OWN
150
+ * structured output (`acceptable` / `multipleAcceptable`) rather than the
151
+ * arbiter's no-reference heuristic. Today `toFailingSample` never fabricates
152
+ * `graderOutput` (GradeResult carries no structured output), so this lights
153
+ * up as soon as a grader supplies it — until then every contract-ambiguity
154
+ * verdict in the optimize loop is heuristic and stays in the mutator signal.
155
+ */
156
+ export declare function hasStructuredAmbiguityEvidence(failing: FailingSample): boolean;
157
+ export type FitnessTriage = {
158
+ /** Sample ids to withhold from the mutator's failure signal this call
159
+ * (freshly classified noise + evidence-backed contract-ambiguity, plus
160
+ * every previously excluded id seen again in this result set). */
161
+ readonly excluded: ReadonlySet<string>;
162
+ /** This call's fresh arbitration counts (queued ids are not re-counted). */
163
+ readonly counts: Readonly<Record<FailureClass, number>>;
164
+ /** Newly classified contract-ambiguity samples (id + arbiter reason).
165
+ * The caller appends ALL of these to its printed dataset-fix queue, but
166
+ * only the `fromGraderEvidence` ones to its sticky exclusion set — the
167
+ * heuristic (no-gold) ones stay in the mutator's failure signal. */
168
+ readonly ambiguous: ReadonlyArray<{
169
+ readonly sampleId: string;
170
+ readonly reason: string;
171
+ readonly fromGraderEvidence: boolean;
172
+ }>;
173
+ /** Previously excluded ids excluded again without re-arbitration. */
174
+ readonly carried: number;
175
+ };
176
+ /**
177
+ * Arbitrate one fitness call's results for the optimize loop. Ids already
178
+ * in `alreadyAmbiguous` (the caller's sticky exclusion set — evidence-backed
179
+ * contract-ambiguity only) are excluded WITHOUT re-arbitration ("exclude
180
+ * them from mutation targets across iterations"): a different candidate's
181
+ * output could flip the class, but the underlying dataset/contract problem
182
+ * is unchanged, so the exclusion is sticky.
183
+ */
184
+ export declare function triageFitnessSamples(opts: {
185
+ readonly samples: ReadonlyArray<SampleResult>;
186
+ readonly samplesById: ReadonlyMap<string, Sample>;
187
+ readonly alreadyAmbiguous: ReadonlySet<string>;
188
+ }): FitnessTriage;
189
+ /** One observable log line per fitness call that excluded or queued
190
+ * anything; undefined when the triage was a no-op (keep the log quiet). */
191
+ export declare function formatFitnessTriageLine(t: FitnessTriage): string | undefined;
192
+ /** Render the end-of-optimize dataset-fix queue (contract-ambiguity ids +
193
+ * arbiter rationale). Empty queue → no lines (print nothing). */
194
+ export declare function formatDatasetFixQueue(queue: ReadonlyMap<string, string>): string[];
195
+ /**
196
+ * Tee an async sample stream into `sink` (first occurrence of each id
197
+ * wins, matching the union's primary-wins dedupe) while passing every
198
+ * sample through unchanged. Lets the eval flow hand the runner a stream
199
+ * AND retain the Sample objects triage/pinning need afterwards — the
200
+ * runner materializes the stream anyway, so this adds no memory ceiling.
201
+ */
202
+ export declare function tapSamples(source: AsyncIterable<Sample>, sink: Map<string, Sample>): AsyncIterable<Sample>;