@hone-ai/cli 1.17.0 → 1.19.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.
@@ -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
+ };