@hone-ai/cli 1.18.0 → 1.20.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.
- package/bin/hone-mcp.js +9 -0
- package/hone-cli.js +2171 -82
- package/lib/agent-eval-judge.js +60 -0
- package/lib/agent-eval-probes-adversarial.js +45 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-faithfulness.js +59 -0
- package/lib/agent-eval-probes-safety.js +28 -0
- package/lib/agent-executor.js +139 -0
- package/lib/architect-config.js +121 -0
- package/lib/bundle-paths.js +141 -0
- package/lib/ci-gate-chooser.js +267 -0
- package/lib/doctor-admin-merge.js +3 -2
- package/lib/doctor-architecture.js +213 -0
- package/lib/emit-pr.js +167 -0
- package/lib/eval-evidence.js +213 -0
- package/lib/eval-graders.js +98 -1
- package/lib/git-env.js +94 -0
- package/lib/git-helpers.js +3 -2
- package/lib/judge-provider.js +63 -0
- package/lib/materialize-diff.js +345 -0
- package/lib/mcp-tools.js +310 -0
- package/lib/patch-apply.js +108 -0
- package/lib/pipeline-config.js +300 -0
- package/lib/pipeline-status.js +27 -2
- package/lib/refresh-knowledge.js +7 -0
- package/lib/release-review-cache.js +162 -0
- package/lib/release-review-config.js +4 -2
- package/lib/schedule-cron.js +141 -0
- package/lib/skill-eval-runner.js +667 -0
- package/lib/stack-paths.js +84 -6
- package/lib/story-classifier-extract.js +2 -1
- package/lib/verify-patch.js +78 -0
- package/lib/verify-pr.js +141 -0
- package/mcp-server.js +67 -0
- package/package.json +8 -5
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* skill-eval-runner.js — HC-010d-followup-1 runtime executor for the
|
|
4
|
+
* `eval-scenarios.json` artifacts that HC-010d emits next to every
|
|
5
|
+
* derived `<stack>-developer/SKILL.md` and `<stack>-architect/SKILL.md`.
|
|
6
|
+
*
|
|
7
|
+
* Two-system clarification (memory: [Don't Confuse hone eval with hone
|
|
8
|
+
* skill-eval]):
|
|
9
|
+
*
|
|
10
|
+
* - `hone eval` (HC-019d, shipped): grades AGENT PROMPTS deterministically
|
|
11
|
+
* against `evals/<agent>/*.eval.yml`. Zero LLM tokens. Tests prompt
|
|
12
|
+
* QUALITY (do the prompt's instructions cover the contract?).
|
|
13
|
+
*
|
|
14
|
+
* - `hone skill-eval` (THIS file, HC-010d-followup-1): grades DERIVED
|
|
15
|
+
* SKILL OUTPUTS against `eval-scenarios.json`. Calls an LLM with the
|
|
16
|
+
* skill content as system prompt + scenario.input as user, scores the
|
|
17
|
+
* response. Tests skill EFFECTIVENESS (do the derived patterns
|
|
18
|
+
* actually make the LLM produce correct outputs?).
|
|
19
|
+
*
|
|
20
|
+
* They answer different questions and coexist; do not unify.
|
|
21
|
+
*
|
|
22
|
+
* Design constraints:
|
|
23
|
+
*
|
|
24
|
+
* 1. Pure helper. No I/O outside `loadSkillEvalScenarios` (which only
|
|
25
|
+
* reads adopter-supplied paths). The LLM call is injected as
|
|
26
|
+
* `callLLM(systemPrompt, userPrompt) => Promise<string>` so the
|
|
27
|
+
* executor unit-tests against stubs.
|
|
28
|
+
*
|
|
29
|
+
* 2. Never throws on user input. validateScenarios already enforces
|
|
30
|
+
* shape; this module's loader and scorer return error envelopes
|
|
31
|
+
* `{ ok: false, errors: [...] }` rather than throwing — same
|
|
32
|
+
* contract memory: [eval-scenarios.js validator never throws].
|
|
33
|
+
*
|
|
34
|
+
* 3. Scoring uses BOTH expected_output_keywords (all must appear,
|
|
35
|
+
* case-insensitive) AND expected_output_format (heuristic regex
|
|
36
|
+
* for the documented format families). Either gate failing →
|
|
37
|
+
* scenario fails.
|
|
38
|
+
*
|
|
39
|
+
* 4. Format heuristics are deliberately LOOSE — they detect "shape"
|
|
40
|
+
* not correctness. A scenario whose keywords pass but format
|
|
41
|
+
* doesn't match is a stronger signal than either alone; both
|
|
42
|
+
* gates together cut false-positives from comments-that-mention-
|
|
43
|
+
* the-keyword-without-implementing-it (the anti-failure mode the
|
|
44
|
+
* HC-010d prompt warned about).
|
|
45
|
+
*
|
|
46
|
+
* 5. Zero-or-near-zero-cost path required (memory: [Pipeline LLM
|
|
47
|
+
* Cost Reduction]). CLI defaults to gh-models (free GH PAT
|
|
48
|
+
* inference); Claude is opt-in via --provider claude.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// ── Output-format heuristics ─────────────────────────────────────
|
|
52
|
+
//
|
|
53
|
+
// One regex per allowed format. Each captures the structural shape of
|
|
54
|
+
// the format — NOT semantic correctness. The intent is:
|
|
55
|
+
//
|
|
56
|
+
// - If keywords match BUT format doesn't, the LLM probably wrote
|
|
57
|
+
// prose mentioning the keywords (anti-pattern HC-010d warns about).
|
|
58
|
+
// - If format matches but keywords don't, the LLM wrote the right
|
|
59
|
+
// SHAPE of code but missed the patterns the skill should encode.
|
|
60
|
+
// - Both must match for pass.
|
|
61
|
+
//
|
|
62
|
+
// Heuristics are intentionally loose — adopters can disagree about
|
|
63
|
+
// indentation, brace style, etc. The format gate's job is to reject
|
|
64
|
+
// "wrong category of output entirely" (e.g., expected apex_method got
|
|
65
|
+
// markdown prose), not to grade code quality.
|
|
66
|
+
//
|
|
67
|
+
// Adding a new format: extend ALLOWED_OUTPUT_FORMATS in
|
|
68
|
+
// `server/src/services/eval-scenarios.js` AND add a heuristic here.
|
|
69
|
+
// Missing heuristic falls through to a generic "non-empty" check so
|
|
70
|
+
// validation doesn't break, but the scenario's format gate becomes
|
|
71
|
+
// "any output passes" — log via the unknownFormat field.
|
|
72
|
+
//
|
|
73
|
+
// Pass-2 review caught that the original heuristics were too loose:
|
|
74
|
+
// `apex_class: /\bclass \w+/i` matched "Python class MyClass:" or
|
|
75
|
+
// natural prose like "My favorite class FooBar in school"; `apex_method:
|
|
76
|
+
// /\b\w+\s+\w+\s*\(/` matched any C-style function declaration across
|
|
77
|
+
// many languages; `sql_query: /SELECT[\s\S]+?FROM/i` matched prose like
|
|
78
|
+
// "Please select these items from the menu". Combined with the
|
|
79
|
+
// substring-includes keyword check, the AND-gate effectively passed
|
|
80
|
+
// markdown prose containing the right vocabulary — the exact failure
|
|
81
|
+
// mode HC-010d set out to prevent.
|
|
82
|
+
//
|
|
83
|
+
// Tightened design:
|
|
84
|
+
// - Each pattern is anchored to start-of-line via `^...` + the `m`
|
|
85
|
+
// flag. Code declarations almost always start at a line boundary;
|
|
86
|
+
// prose almost never does.
|
|
87
|
+
// - Each pattern requires the structural elements a language demands
|
|
88
|
+
// for a declaration: access modifiers (Apex/Java/C#), JSDoc tags
|
|
89
|
+
// (NetSuite), required keyword sequences (`func` for Go, `def`
|
|
90
|
+
// for Python).
|
|
91
|
+
// - Patterns that previously used `i` are case-sensitive where the
|
|
92
|
+
// target syntax is case-sensitive in practice (function/class
|
|
93
|
+
// keywords are lowercase in JS/TS/Python/Go/Java/Apex/C#; SQL
|
|
94
|
+
// verbs are uppercase by convention in migrations + queries).
|
|
95
|
+
//
|
|
96
|
+
// Each entry's "rejects" comment lists the adversarial prose case the
|
|
97
|
+
// behavioral suite drives — so a future loosening that re-admits prose
|
|
98
|
+
// fails a named test.
|
|
99
|
+
const OUTPUT_FORMAT_HEURISTICS = Object.freeze({
|
|
100
|
+
// ── Salesforce ─────────────────────────────────────────────────
|
|
101
|
+
//
|
|
102
|
+
// Apex is case-INSENSITIVE at the language level (the runtime
|
|
103
|
+
// accepts `PUBLIC CLASS` and `public class` equivalently). The
|
|
104
|
+
// discriminator is the structural shape — line-anchor + access
|
|
105
|
+
// modifier — not case. Rejects "Python class MyClass:" (no Apex
|
|
106
|
+
// access modifier at line start), prose mentioning "class FooCtrl"
|
|
107
|
+
// (no line anchor), and Java/C# class declarations (would still
|
|
108
|
+
// match the structural shape, but they declare java_class /
|
|
109
|
+
// csharp_method via their own heuristics).
|
|
110
|
+
//
|
|
111
|
+
// Pass-3 review caught that the bare `class \w+` shape still matched
|
|
112
|
+
// prose like "public class definitions in Apex" — `definitions`
|
|
113
|
+
// satisfies `\w+`. Fix: require structural elements that follow a
|
|
114
|
+
// real class declaration — either an `extends`/`implements` clause
|
|
115
|
+
// OR an opening `{` (with optional whitespace/newline before).
|
|
116
|
+
//
|
|
117
|
+
// Pass-4 review caught: bare `class \w+` rejected generic class
|
|
118
|
+
// declarations like `public class Pair<T, U> { ... }` because the
|
|
119
|
+
// next char after `\w+` is `<`, not `\s+extends/implements` or
|
|
120
|
+
// `\s*{`. Apex utility libraries routinely declare generic
|
|
121
|
+
// wrappers (Pair, Wrapper, Result). Fix: insert optional
|
|
122
|
+
// `(?:<[\w,\s.<>]+>)?` after the class name to accept type
|
|
123
|
+
// parameters.
|
|
124
|
+
apex_class:
|
|
125
|
+
/^[ \t]*(?:@\w+\s+)*(?:public|global|private|protected)\s+(?:with\s+sharing\s+|without\s+sharing\s+|inherited\s+sharing\s+)?(?:virtual\s+|abstract\s+)?class\s+\w+(?:<[\w,\s.<>]+>)?(?:\s+(?:extends|implements)\s+[\w,\s.<>]+)?\s*\{/im,
|
|
126
|
+
// Apex method: line-anchor + access modifier + return type + name +
|
|
127
|
+
// params + opening brace. Pass-3 review caught two false negatives:
|
|
128
|
+
// (a) Inner-class methods that inherit access from the enclosing
|
|
129
|
+
// class — `class Inner { void doThing(String s) { ... } }`.
|
|
130
|
+
// Valid Apex but original regex required an explicit access
|
|
131
|
+
// modifier on every method. Pass-3 adds an alternative branch
|
|
132
|
+
// that matches "indented method without modifier" — INDENT
|
|
133
|
+
// required so top-level prose without modifier doesn't slip
|
|
134
|
+
// through.
|
|
135
|
+
// (b) Legacy `testMethod` keyword: `public static testMethod void
|
|
136
|
+
// testFoo() { ... }`. Pass-3 allows `testMethod` in the
|
|
137
|
+
// optional-modifier slot alongside override/virtual.
|
|
138
|
+
// Pass-4 review caught: return-type token class `[\w<>,\[\]]+`
|
|
139
|
+
// does NOT include whitespace, so nested generics with internal
|
|
140
|
+
// spaces (`Map<String, List<Account>>`) are rejected. Apex
|
|
141
|
+
// SObject-collection idioms (Map<Id, List<Account>>, etc.) are
|
|
142
|
+
// ubiquitous. Fix: include `\s` in the return-type class
|
|
143
|
+
// (`[\w<>,\s\[\]]+`) — mirrors java_method's already-correct
|
|
144
|
+
// class. Applied to BOTH alternation branches.
|
|
145
|
+
apex_method:
|
|
146
|
+
/^[ \t]*(?:@\w+\s+)*(?:public|global|private|protected)\s+(?:static\s+)?(?:override\s+|virtual\s+|testMethod\s+)?[\w<>,\s\[\]]+\s+\w+\s*\([^)]*\)\s*\{|^[ \t]{2,}(?:@\w+\s+)*(?:static\s+)?(?:override\s+|virtual\s+|testMethod\s+)?[\w<>,\s\[\]]+\s+\w+\s*\([^)]*\)\s*\{/im,
|
|
147
|
+
// Apex test: requires @isTest at line-start OR Test.startTest() at
|
|
148
|
+
// line-start. Pass-3 caught that the second alternative was not
|
|
149
|
+
// line-anchored, so prose "Use Test.startTest() to invoke test
|
|
150
|
+
// methods" matched. Both branches now require `^[ \t]*`.
|
|
151
|
+
apex_test:
|
|
152
|
+
/^[ \t]*@isTest\b|^[ \t]*Test\.startTest\s*\(/im,
|
|
153
|
+
// LWC requires BOTH the template tag AND the LightningElement
|
|
154
|
+
// class — prose mentioning "extends LightningElement" alone is not a
|
|
155
|
+
// component. Pass-3 caught that the `<template>...</template>`
|
|
156
|
+
// branch was not line-anchored — inline HTML in prose matched. Both
|
|
157
|
+
// branches now require `^[ \t]*` (templates almost always start at
|
|
158
|
+
// line boundaries in real .html files / template literals).
|
|
159
|
+
lwc_component:
|
|
160
|
+
/^[ \t]*<template\b[\s\S]+?<\/template>|^[ \t]*(?:export\s+default\s+)?class\s+\w+\s+extends\s+LightningElement\b/m,
|
|
161
|
+
visualforce_page:
|
|
162
|
+
/<apex:page\b[^>]*>/i,
|
|
163
|
+
|
|
164
|
+
// ── NetSuite ───────────────────────────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// SuiteScript 2.x modules require either an AMD-style define([...])
|
|
167
|
+
// OR a JSDoc with @NApiVersion/@NScriptType. The original
|
|
168
|
+
// `\bN/(record|search|...)` was too permissive (any prose mentioning
|
|
169
|
+
// "N/A" matched).
|
|
170
|
+
suitescript_module:
|
|
171
|
+
/^[ \t]*define\s*\(\s*\[\s*['"]N\/|@NApiVersion\b|@NScriptType\b/m,
|
|
172
|
+
suitescript_restlet:
|
|
173
|
+
/@NScriptType\s+Restlet\b/,
|
|
174
|
+
// UserEvent requires the JSDoc tag at module-doc level. beforeSubmit/
|
|
175
|
+
// afterSubmit alone are also valid in NetSuite contexts but match too
|
|
176
|
+
// broadly (any JS file with those function names); require the
|
|
177
|
+
// JSDoc tag.
|
|
178
|
+
suitescript_userevent:
|
|
179
|
+
/@NScriptType\s+UserEventScript\b/,
|
|
180
|
+
// SuiteQL requires line-anchored SELECT (rejects prose "please
|
|
181
|
+
// select these items from the menu") + FROM + an identifier.
|
|
182
|
+
suiteql_query:
|
|
183
|
+
/^[ \t]*SELECT\s+[\s\S]+?\s+FROM\s+\w+/im,
|
|
184
|
+
|
|
185
|
+
// ── Node / TS / Java / Python / C# / Go ───────────────────────
|
|
186
|
+
//
|
|
187
|
+
// js_function: line-anchored function declaration OR const arrow.
|
|
188
|
+
// Rejects prose "Use a function to handle the callback" or "event
|
|
189
|
+
// handler: callback => promise" inside an explanation.
|
|
190
|
+
js_function:
|
|
191
|
+
/^[ \t]*(?:async\s+)?function\s+\w+\s*\(|^[ \t]*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?\([^)]*\)\s*=>/m,
|
|
192
|
+
// ts_function: same as js_function but optionally allows type
|
|
193
|
+
// annotations on params and return type.
|
|
194
|
+
ts_function:
|
|
195
|
+
/^[ \t]*(?:export\s+)?(?:async\s+)?function\s+\w+\s*(?:<[^>]+>)?\s*\(|^[ \t]*(?:export\s+)?(?:const|let|var)\s+\w+(?:\s*:\s*[\w<>,\s\[\]|]+)?\s*=\s*(?:async\s+)?\([^)]*\)(?:\s*:\s*[\w<>,\s\[\]|]+)?\s*=>/m,
|
|
196
|
+
// Java class: line-anchored + access modifier. Pass-3 review caught
|
|
197
|
+
// that bare `class \w+` matched prose like "public class definitions"
|
|
198
|
+
// — `definitions` satisfies `\w+`. Fix: require `extends`/`implements`
|
|
199
|
+
// clause OR an opening `{` so the regex distinguishes a declaration
|
|
200
|
+
// from a noun phrase.
|
|
201
|
+
// Pass-4 review caught: same generic-class issue as apex_class —
|
|
202
|
+
// bare `class \w+` rejected `public class Pair<T, U> { ... }`. Fix:
|
|
203
|
+
// optional `(?:<[\w,\s.<>]+>)?` after class name.
|
|
204
|
+
java_class:
|
|
205
|
+
/^[ \t]*(?:@\w+\s+)*(?:public|private|protected)\s+(?:final\s+|abstract\s+|static\s+)*class\s+\w+(?:<[\w,\s.<>]+>)?(?:\s+(?:extends|implements)\s+[\w,\s.<>]+)?\s*\{/m,
|
|
206
|
+
// Java method: line-anchored + access modifier + return type.
|
|
207
|
+
// Pass-3 review caught: original required `\{` ending, rejecting
|
|
208
|
+
// valid interface methods like `public abstract Foo bar();`. Now
|
|
209
|
+
// accepts EITHER opening brace (concrete method) OR semicolon
|
|
210
|
+
// (abstract/interface method).
|
|
211
|
+
java_method:
|
|
212
|
+
/^[ \t]*(?:@\w+\s+)*(?:public|private|protected)\s+(?:static\s+|final\s+|synchronized\s+|abstract\s+|default\s+)*[\w<>,\s\[\]]+\s+\w+\s*\([^)]*\)\s*(?:throws\s+[\w,\s]+)?\s*[{;]/m,
|
|
213
|
+
// Python: already anchored; keep but tighten the trailing colon
|
|
214
|
+
// requirement so "def foo(x" half-written prose doesn't match.
|
|
215
|
+
python_function:
|
|
216
|
+
/^[ \t]*(?:async\s+)?def\s+\w+\s*\([^)]*\)\s*(?:->\s*[\w\[\],\s]+\s*)?:/m,
|
|
217
|
+
python_class:
|
|
218
|
+
/^[ \t]*class\s+\w+(?:\s*\([^)]*\))?\s*:/m,
|
|
219
|
+
// C# method: line-anchored + access modifier.
|
|
220
|
+
csharp_method:
|
|
221
|
+
/^[ \t]*(?:\[[\w()=,"' \t]+\]\s*)*(?:public|private|protected|internal)\s+(?:static\s+|async\s+|override\s+|virtual\s+|abstract\s+)*[\w<>,\s\[\]?]+\s+\w+\s*\([^)]*\)\s*\{/m,
|
|
222
|
+
// Go func: line-anchored, requires `func`. Optional receiver group.
|
|
223
|
+
go_function:
|
|
224
|
+
/^[ \t]*func\s+(?:\(\s*\w+\s+\*?\w+\s*\)\s+)?\w+\s*\(/m,
|
|
225
|
+
|
|
226
|
+
// ── Data / infra ───────────────────────────────────────────────
|
|
227
|
+
//
|
|
228
|
+
// SQL queries: line-anchored verb. Uppercase preferred by
|
|
229
|
+
// convention, but real-world code uses both; case-insensitive at
|
|
230
|
+
// line start is acceptable. Rejects prose "please select all from
|
|
231
|
+
// the available options".
|
|
232
|
+
sql_query:
|
|
233
|
+
/^[ \t]*(?:SELECT\s+[\s\S]+?\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/im,
|
|
234
|
+
// SOQL: line-anchored SELECT + FROM + Salesforce object (standard
|
|
235
|
+
// or __c suffix).
|
|
236
|
+
soql_query:
|
|
237
|
+
/^[ \t]*SELECT\s+[\s\S]+?\s+FROM\s+\w+(?:__c)?\b/im,
|
|
238
|
+
// SQL migration: line-anchored DDL.
|
|
239
|
+
sql_migration:
|
|
240
|
+
/^[ \t]*(?:CREATE\s+(?:TABLE|INDEX|FUNCTION|TYPE|VIEW|MATERIALIZED\s+VIEW)|ALTER\s+TABLE|DROP\s+(?:TABLE|INDEX))\b/im,
|
|
241
|
+
// dbt: Jinja2 template tags are distinctive — prose can't easily
|
|
242
|
+
// contain `{{ config(...)`.
|
|
243
|
+
dbt_model:
|
|
244
|
+
/\{\{\s*(?:config|ref|source)\s*\(/,
|
|
245
|
+
terraform_module:
|
|
246
|
+
/^[ \t]*(?:resource|module|data)\s+"[\w_-]+"/m,
|
|
247
|
+
terraform_resource:
|
|
248
|
+
/^[ \t]*resource\s+"[\w_-]+"\s+"[\w_-]+"\s*\{/m,
|
|
249
|
+
|
|
250
|
+
// ── Config + prose ─────────────────────────────────────────────
|
|
251
|
+
//
|
|
252
|
+
// config_yaml requires at least 2 lines of key-value pairs to
|
|
253
|
+
// distinguish from a single inline `key: value` mention. Pass-3
|
|
254
|
+
// review caught that the original `\n[ \t]+` required the SECOND
|
|
255
|
+
// key to be INDENTED — but real top-level YAML (Kubernetes
|
|
256
|
+
// manifests, GitHub workflows) has all keys at column 0. Pass-3
|
|
257
|
+
// accepts EITHER indented (nested) OR column-0 (top-level) second
|
|
258
|
+
// keys via `[ \t]*` (greedy zero-or-more) instead of `[ \t]+`
|
|
259
|
+
// (one-or-more).
|
|
260
|
+
config_yaml:
|
|
261
|
+
/^[ \t]*[\w-]+\s*:\s*\S[\s\S]*?\n[ \t]*[\w-]+\s*:/m,
|
|
262
|
+
// config_json: line-anchored object literal with at least one
|
|
263
|
+
// quoted key.
|
|
264
|
+
config_json:
|
|
265
|
+
/^[ \t]*\{[\s\S]*?"[\w-]+"\s*:/m,
|
|
266
|
+
// Prose formats need a length floor — single-paragraph responses
|
|
267
|
+
// don't count as design docs even if they mention the keywords.
|
|
268
|
+
markdown_explanation:
|
|
269
|
+
/(?:^|\n)#{1,6}\s+\S[\s\S]{50,}/,
|
|
270
|
+
design_doc:
|
|
271
|
+
/(?:^|\n)#{1,3}\s+\S[\s\S]{200,}/,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run the format heuristic for a given expected_output_format. Returns
|
|
276
|
+
* { matched: boolean, unknownFormat: boolean }. Unknown formats fall
|
|
277
|
+
* through to a "non-empty after trim" check + a flag so the executor
|
|
278
|
+
* can warn the operator that the format gate degraded to "any output."
|
|
279
|
+
*
|
|
280
|
+
* @param {string} format — value from scenario.expected_output_format
|
|
281
|
+
* @param {string} response — LLM output
|
|
282
|
+
* @returns {{ matched: boolean, unknownFormat: boolean }}
|
|
283
|
+
*/
|
|
284
|
+
function matchFormat(format, response) {
|
|
285
|
+
if (typeof response !== 'string') return { matched: false, unknownFormat: false };
|
|
286
|
+
const rx = OUTPUT_FORMAT_HEURISTICS[format];
|
|
287
|
+
if (!rx) {
|
|
288
|
+
// Degraded check: any non-empty response. Caller flags via
|
|
289
|
+
// unknownFormat so the warning surfaces. (This branch shouldn't
|
|
290
|
+
// hit in practice — validateScenarios rejects unknown formats —
|
|
291
|
+
// but defending against drift between the validator's allowlist
|
|
292
|
+
// and this module's heuristic table is cheap.)
|
|
293
|
+
return { matched: response.trim().length > 0, unknownFormat: true };
|
|
294
|
+
}
|
|
295
|
+
return { matched: rx.test(response), unknownFormat: false };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Score a single scenario's LLM response. Pure function; both gates
|
|
300
|
+
* (keywords + format) must pass.
|
|
301
|
+
*
|
|
302
|
+
* Keyword matching is CASE-INSENSITIVE substring (the prompt asks for
|
|
303
|
+
* "keywords that ONLY co-occur in a real solution," not exact-case
|
|
304
|
+
* identifiers — LLMs lowercase + paraphrase freely).
|
|
305
|
+
*
|
|
306
|
+
* @param {object} scenario — already validated via validateScenarios
|
|
307
|
+
* @param {string} llmResponse
|
|
308
|
+
* @returns {{
|
|
309
|
+
* passed: boolean,
|
|
310
|
+
* keywordHits: string[],
|
|
311
|
+
* keywordMisses: string[],
|
|
312
|
+
* formatMatched: boolean,
|
|
313
|
+
* unknownFormat: boolean,
|
|
314
|
+
* failures: string[]
|
|
315
|
+
* }}
|
|
316
|
+
*/
|
|
317
|
+
function scoreScenario({ scenario, llmResponse }) {
|
|
318
|
+
const responseLower = (llmResponse || '').toLowerCase();
|
|
319
|
+
const hits = [];
|
|
320
|
+
const misses = [];
|
|
321
|
+
for (const kw of scenario.expected_output_keywords) {
|
|
322
|
+
if (responseLower.includes(kw.toLowerCase())) hits.push(kw);
|
|
323
|
+
else misses.push(kw);
|
|
324
|
+
}
|
|
325
|
+
const { matched: formatMatched, unknownFormat } = matchFormat(
|
|
326
|
+
scenario.expected_output_format,
|
|
327
|
+
llmResponse,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const failures = [];
|
|
331
|
+
if (misses.length > 0) {
|
|
332
|
+
failures.push(`missing keywords: ${misses.join(', ')}`);
|
|
333
|
+
}
|
|
334
|
+
if (!formatMatched) {
|
|
335
|
+
failures.push(`format heuristic for "${scenario.expected_output_format}" did not match`);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
passed: failures.length === 0,
|
|
339
|
+
keywordHits: hits,
|
|
340
|
+
keywordMisses: misses,
|
|
341
|
+
formatMatched,
|
|
342
|
+
unknownFormat,
|
|
343
|
+
failures,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Run a single scenario end-to-end: call LLM with skill as system
|
|
349
|
+
* prompt + scenario.input as user, then score. Errors from the LLM
|
|
350
|
+
* call are captured into the result (does NOT throw).
|
|
351
|
+
*
|
|
352
|
+
* @param {object} opts
|
|
353
|
+
* @param {string} opts.skillContent — the SKILL.md body
|
|
354
|
+
* @param {object} opts.scenario — validated scenario
|
|
355
|
+
* @param {(sys: string, user: string) => Promise<string>} opts.callLLM
|
|
356
|
+
* @returns {Promise<object>} — scenario result with verdict + diagnostics
|
|
357
|
+
*/
|
|
358
|
+
async function runSkillEvalScenario({ skillContent, scenario, callLLM }) {
|
|
359
|
+
const systemPrompt = skillContent;
|
|
360
|
+
const userPrompt = scenario.input;
|
|
361
|
+
let llmResponse, llmError = null;
|
|
362
|
+
try {
|
|
363
|
+
llmResponse = await callLLM(systemPrompt, userPrompt);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
llmError = e?.message || String(e);
|
|
366
|
+
llmResponse = '';
|
|
367
|
+
}
|
|
368
|
+
// Result envelope — keep shape identical on happy + error paths so
|
|
369
|
+
// formatResults never has to branch. Pass-2 review caught a missing
|
|
370
|
+
// `expected_output_format` field that surfaced as a misleading
|
|
371
|
+
// "format heuristic missing for \"security\"" warning (the format
|
|
372
|
+
// name is what the operator needs, not the category).
|
|
373
|
+
const baseEnvelope = {
|
|
374
|
+
id: scenario.id,
|
|
375
|
+
name: scenario.name,
|
|
376
|
+
category: scenario.category,
|
|
377
|
+
expected_output_format: scenario.expected_output_format,
|
|
378
|
+
// Preserve tags on every code path — per-tag aggregation in
|
|
379
|
+
// runAllSkillScenarios is the operator's "did this HC-010c rule's
|
|
380
|
+
// eval succeed?" view. Dropping tags on error would silently
|
|
381
|
+
// under-count rule coverage exactly when a rule fails in a noisy
|
|
382
|
+
// way (LLM rate limit, transient network), making the error look
|
|
383
|
+
// like "rule not exercised."
|
|
384
|
+
tags: scenario.tags || [],
|
|
385
|
+
};
|
|
386
|
+
if (llmError) {
|
|
387
|
+
return {
|
|
388
|
+
...baseEnvelope,
|
|
389
|
+
result: 'error',
|
|
390
|
+
passed: false,
|
|
391
|
+
llmError,
|
|
392
|
+
keywordHits: [],
|
|
393
|
+
keywordMisses: scenario.expected_output_keywords.slice(),
|
|
394
|
+
formatMatched: false,
|
|
395
|
+
unknownFormat: false,
|
|
396
|
+
failures: [`LLM call failed: ${llmError}`],
|
|
397
|
+
response: '',
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const scored = scoreScenario({ scenario, llmResponse });
|
|
401
|
+
return {
|
|
402
|
+
...baseEnvelope,
|
|
403
|
+
result: scored.passed ? 'pass' : 'fail',
|
|
404
|
+
passed: scored.passed,
|
|
405
|
+
llmError: null,
|
|
406
|
+
keywordHits: scored.keywordHits,
|
|
407
|
+
keywordMisses: scored.keywordMisses,
|
|
408
|
+
formatMatched: scored.formatMatched,
|
|
409
|
+
unknownFormat: scored.unknownFormat,
|
|
410
|
+
failures: scored.failures,
|
|
411
|
+
response: llmResponse,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Run every scenario. Supports fail-fast + a progress callback so the
|
|
417
|
+
* CLI can stream "running N of M..." messages.
|
|
418
|
+
*
|
|
419
|
+
* @param {object} opts
|
|
420
|
+
* @param {object[]} opts.scenarios
|
|
421
|
+
* @param {string} opts.skillContent
|
|
422
|
+
* @param {Function} opts.callLLM
|
|
423
|
+
* @param {boolean} [opts.failFast=false]
|
|
424
|
+
* @param {Function} [opts.onProgress] — called with (current, total, lastResult)
|
|
425
|
+
* @returns {Promise<{
|
|
426
|
+
* total: number,
|
|
427
|
+
* passed: number,
|
|
428
|
+
* failed: number,
|
|
429
|
+
* errors: number,
|
|
430
|
+
* results: object[],
|
|
431
|
+
* perCategory: Record<string, { total: number, passed: number, failed: number }>,
|
|
432
|
+
* perTag: Record<string, { total: number, passed: number, failed: number }>
|
|
433
|
+
* }>}
|
|
434
|
+
*/
|
|
435
|
+
async function runAllSkillScenarios({ scenarios, skillContent, callLLM, failFast = false, onProgress }) {
|
|
436
|
+
const results = [];
|
|
437
|
+
for (let i = 0; i < scenarios.length; i++) {
|
|
438
|
+
const r = await runSkillEvalScenario({
|
|
439
|
+
skillContent, scenario: scenarios[i], callLLM,
|
|
440
|
+
});
|
|
441
|
+
results.push(r);
|
|
442
|
+
if (typeof onProgress === 'function') {
|
|
443
|
+
onProgress(i + 1, scenarios.length, r);
|
|
444
|
+
}
|
|
445
|
+
if (failFast && r.result !== 'pass') break;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const perCategory = {};
|
|
449
|
+
const perTag = {};
|
|
450
|
+
for (const r of results) {
|
|
451
|
+
perCategory[r.category] = perCategory[r.category]
|
|
452
|
+
|| { total: 0, passed: 0, failed: 0, errors: 0 };
|
|
453
|
+
perCategory[r.category].total += 1;
|
|
454
|
+
perCategory[r.category][r.result === 'pass' ? 'passed'
|
|
455
|
+
: r.result === 'fail' ? 'failed'
|
|
456
|
+
: 'errors'] += 1;
|
|
457
|
+
for (const tag of (r.tags || [])) {
|
|
458
|
+
perTag[tag] = perTag[tag] || { total: 0, passed: 0, failed: 0, errors: 0 };
|
|
459
|
+
perTag[tag].total += 1;
|
|
460
|
+
perTag[tag][r.result === 'pass' ? 'passed'
|
|
461
|
+
: r.result === 'fail' ? 'failed'
|
|
462
|
+
: 'errors'] += 1;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
total: results.length,
|
|
468
|
+
passed: results.filter(r => r.result === 'pass').length,
|
|
469
|
+
failed: results.filter(r => r.result === 'fail').length,
|
|
470
|
+
errors: results.filter(r => r.result === 'error').length,
|
|
471
|
+
results,
|
|
472
|
+
perCategory,
|
|
473
|
+
perTag,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Locate + load a skill's SKILL.md + adjacent eval-scenarios.json,
|
|
479
|
+
* validating the JSON via the shared validator. Pure function w/r/t
|
|
480
|
+
* arguments — all filesystem access goes through injected helpers so
|
|
481
|
+
* tests can drive against in-memory fixtures.
|
|
482
|
+
*
|
|
483
|
+
* Path resolution mirrors HC-019y AI-tool path mapping:
|
|
484
|
+
* 1. `<repoRoot>/.claude/agents/<skill>/SKILL.md` (HC-019y canonical)
|
|
485
|
+
* 2. `<repoRoot>/.github/skills/<skill>/SKILL.md` (legacy)
|
|
486
|
+
* 3. `<repoRoot>/.github/skills/<skill>.md` (older legacy)
|
|
487
|
+
*
|
|
488
|
+
* eval-scenarios.json must be ADJACENT to the SKILL.md.
|
|
489
|
+
*
|
|
490
|
+
* @param {object} opts
|
|
491
|
+
* @param {string} opts.repoRoot
|
|
492
|
+
* @param {string} opts.skillName
|
|
493
|
+
* @param {object} opts.fs — node:fs subset { existsSync, readFileSync }
|
|
494
|
+
* @param {object} opts.path — node:path
|
|
495
|
+
* @param {Function} opts.validateScenarios — server/src/services/eval-scenarios.js
|
|
496
|
+
* @returns {{
|
|
497
|
+
* ok: true,
|
|
498
|
+
* scenarios: object[],
|
|
499
|
+
* skill: string,
|
|
500
|
+
* skillContent: string,
|
|
501
|
+
* skillPath: string,
|
|
502
|
+
* scenariosPath: string,
|
|
503
|
+
* } | {
|
|
504
|
+
* ok: false,
|
|
505
|
+
* errors: { path: string, message: string }[],
|
|
506
|
+
* }}
|
|
507
|
+
*/
|
|
508
|
+
function loadSkillEvalScenarios({ repoRoot, skillName, fs, path, validateScenarios }) {
|
|
509
|
+
// Search order — first match wins. Each candidate is a (skillPath,
|
|
510
|
+
// dir) pair; the adjacent JSON lives in dir + 'eval-scenarios.json'.
|
|
511
|
+
const candidates = [
|
|
512
|
+
{
|
|
513
|
+
skill: path.join(repoRoot, '.claude', 'agents', skillName, 'SKILL.md'),
|
|
514
|
+
scenarios: path.join(repoRoot, '.claude', 'agents', skillName, 'eval-scenarios.json'),
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
skill: path.join(repoRoot, '.github', 'skills', skillName, 'SKILL.md'),
|
|
518
|
+
scenarios: path.join(repoRoot, '.github', 'skills', skillName, 'eval-scenarios.json'),
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
skill: path.join(repoRoot, '.github', 'skills', `${skillName}.md`),
|
|
522
|
+
scenarios: path.join(repoRoot, '.github', 'skills', `${skillName}.eval-scenarios.json`),
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
let found = null;
|
|
527
|
+
for (const c of candidates) {
|
|
528
|
+
if (fs.existsSync(c.skill)) {
|
|
529
|
+
found = c;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!found) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
errors: [{
|
|
537
|
+
path: '$',
|
|
538
|
+
message: `SKILL.md for "${skillName}" not found under .claude/agents/ or .github/skills/. ` +
|
|
539
|
+
`Run \`hone derive\` to generate it.`,
|
|
540
|
+
}],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (!fs.existsSync(found.scenarios)) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
errors: [{
|
|
547
|
+
path: '$',
|
|
548
|
+
message: `eval-scenarios.json not found adjacent to ${found.skill}. ` +
|
|
549
|
+
`Re-run \`hone derive\` to emit it (HC-010d Phase 2d).`,
|
|
550
|
+
}],
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let raw, parsed;
|
|
555
|
+
try {
|
|
556
|
+
raw = fs.readFileSync(found.scenarios, 'utf8');
|
|
557
|
+
} catch (e) {
|
|
558
|
+
return {
|
|
559
|
+
ok: false,
|
|
560
|
+
errors: [{ path: '$', message: `failed to read ${found.scenarios}: ${e.message}` }],
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
parsed = JSON.parse(raw);
|
|
565
|
+
} catch (e) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
errors: [{ path: '$', message: `${found.scenarios}: JSON parse failed — ${e.message}` }],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const v = validateScenarios(parsed);
|
|
572
|
+
if (!v.ok) return v;
|
|
573
|
+
|
|
574
|
+
let skillContent;
|
|
575
|
+
try {
|
|
576
|
+
skillContent = fs.readFileSync(found.skill, 'utf8');
|
|
577
|
+
} catch (e) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
errors: [{ path: '$', message: `failed to read ${found.skill}: ${e.message}` }],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (skillContent.trim().length === 0) {
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
errors: [{ path: '$', message: `${found.skill} is empty — nothing to evaluate` }],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
ok: true,
|
|
592
|
+
scenarios: v.scenarios,
|
|
593
|
+
skill: parsed.skill,
|
|
594
|
+
skillContent,
|
|
595
|
+
skillPath: found.skill,
|
|
596
|
+
scenariosPath: found.scenarios,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Pretty / JSON renderer. Mirrors `formatResults` in eval-runner.js so
|
|
602
|
+
* CLI users get consistent output across `hone eval` and `hone skill-eval`.
|
|
603
|
+
*
|
|
604
|
+
* @param {object} summary — output of runAllSkillScenarios
|
|
605
|
+
* @param {string} fmt — 'pretty' | 'json'
|
|
606
|
+
* @returns {string}
|
|
607
|
+
*/
|
|
608
|
+
function formatResults(summary, fmt = 'pretty') {
|
|
609
|
+
if (fmt === 'json') return JSON.stringify(summary, null, 2);
|
|
610
|
+
|
|
611
|
+
const lines = [];
|
|
612
|
+
lines.push('Hone Skill Eval — Scenario Results');
|
|
613
|
+
lines.push('===================================');
|
|
614
|
+
lines.push('');
|
|
615
|
+
for (const r of summary.results) {
|
|
616
|
+
const icon = r.result === 'pass' ? '✓' // ✓
|
|
617
|
+
: r.result === 'fail' ? '✗' // ✗
|
|
618
|
+
: '!';
|
|
619
|
+
const label = r.tags && r.tags.length
|
|
620
|
+
? `${r.id} [${r.tags.join(', ')}]`
|
|
621
|
+
: r.id;
|
|
622
|
+
lines.push(`${icon} ${label} — ${r.name}`);
|
|
623
|
+
// Default failures to [] defensively. Pass-2 review caught that
|
|
624
|
+
// a synthetic summary missing the `failures` field (e.g., from a
|
|
625
|
+
// dashboard mock or aggregator) crashed the renderer with
|
|
626
|
+
// "r.failures is not iterable". Public exports should tolerate
|
|
627
|
+
// partial inputs.
|
|
628
|
+
if (r.result !== 'pass') {
|
|
629
|
+
for (const f of (r.failures || [])) lines.push(` - ${f}`);
|
|
630
|
+
}
|
|
631
|
+
if (r.unknownFormat) {
|
|
632
|
+
// Pass-2 review caught: printed r.category (security /
|
|
633
|
+
// correctness / etc.) instead of the actual format name. The
|
|
634
|
+
// operator needs to know WHICH format the validator added that
|
|
635
|
+
// the executor's heuristic table doesn't cover.
|
|
636
|
+
const fmtName = r.expected_output_format || '<format name missing from result envelope>';
|
|
637
|
+
lines.push(` ⚠ format heuristic missing for "${fmtName}" — gate degraded to non-empty check`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
lines.push('');
|
|
641
|
+
lines.push(`Total: ${summary.total} pass: ${summary.passed} fail: ${summary.failed} error: ${summary.errors}`);
|
|
642
|
+
if (Object.keys(summary.perCategory).length > 0) {
|
|
643
|
+
lines.push('');
|
|
644
|
+
lines.push('By category:');
|
|
645
|
+
for (const [cat, c] of Object.entries(summary.perCategory)) {
|
|
646
|
+
lines.push(` ${cat.padEnd(16)} ${c.passed}/${c.total} pass`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (Object.keys(summary.perTag).length > 0) {
|
|
650
|
+
lines.push('');
|
|
651
|
+
lines.push('By tag (HC-010c rule id):');
|
|
652
|
+
for (const [tag, c] of Object.entries(summary.perTag)) {
|
|
653
|
+
lines.push(` ${tag.padEnd(20)} ${c.passed}/${c.total} pass`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return lines.join('\n');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
module.exports = {
|
|
660
|
+
OUTPUT_FORMAT_HEURISTICS,
|
|
661
|
+
matchFormat,
|
|
662
|
+
scoreScenario,
|
|
663
|
+
runSkillEvalScenario,
|
|
664
|
+
runAllSkillScenarios,
|
|
665
|
+
loadSkillEvalScenarios,
|
|
666
|
+
formatResults,
|
|
667
|
+
};
|