neuro-cli 4.1.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 (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +510 -0
  3. package/dist/advisor/advisor.d.ts +50 -0
  4. package/dist/advisor/advisor.js +178 -0
  5. package/dist/agents/base.d.ts +62 -0
  6. package/dist/agents/base.js +215 -0
  7. package/dist/agents/orchestrator.d.ts +46 -0
  8. package/dist/agents/orchestrator.js +192 -0
  9. package/dist/agents/team.d.ts +51 -0
  10. package/dist/agents/team.js +210 -0
  11. package/dist/api/models.d.ts +23 -0
  12. package/dist/api/models.js +514 -0
  13. package/dist/api/ollama.d.ts +153 -0
  14. package/dist/api/ollama.js +751 -0
  15. package/dist/api/openrouter.d.ts +55 -0
  16. package/dist/api/openrouter.js +223 -0
  17. package/dist/commands/commands.d.ts +43 -0
  18. package/dist/commands/commands.js +308 -0
  19. package/dist/config/config.d.ts +8 -0
  20. package/dist/config/config.js +311 -0
  21. package/dist/context/compaction.d.ts +54 -0
  22. package/dist/context/compaction.js +251 -0
  23. package/dist/context/custom-agents.d.ts +107 -0
  24. package/dist/context/custom-agents.js +397 -0
  25. package/dist/context/custom-tools.d.ts +120 -0
  26. package/dist/context/custom-tools.js +564 -0
  27. package/dist/context/git-checkpoint.d.ts +52 -0
  28. package/dist/context/git-checkpoint.js +240 -0
  29. package/dist/context/neuro-md.d.ts +48 -0
  30. package/dist/context/neuro-md.js +202 -0
  31. package/dist/context/neuroignore.d.ts +102 -0
  32. package/dist/context/neuroignore.js +441 -0
  33. package/dist/context/repo-map.d.ts +38 -0
  34. package/dist/context/repo-map.js +220 -0
  35. package/dist/context/skill-standard.d.ts +262 -0
  36. package/dist/context/skill-standard.js +1156 -0
  37. package/dist/context/skill-system.d.ts +75 -0
  38. package/dist/context/skill-system.js +578 -0
  39. package/dist/context/tree-sitter.d.ts +99 -0
  40. package/dist/context/tree-sitter.js +1956 -0
  41. package/dist/core/acp.d.ts +325 -0
  42. package/dist/core/acp.js +1498 -0
  43. package/dist/core/api-server.d.ts +143 -0
  44. package/dist/core/api-server.js +550 -0
  45. package/dist/core/approval.d.ts +81 -0
  46. package/dist/core/approval.js +432 -0
  47. package/dist/core/auto-compact.d.ts +127 -0
  48. package/dist/core/auto-compact.js +436 -0
  49. package/dist/core/auto-mode.d.ts +232 -0
  50. package/dist/core/auto-mode.js +831 -0
  51. package/dist/core/background-session.d.ts +166 -0
  52. package/dist/core/background-session.js +696 -0
  53. package/dist/core/cicd.d.ts +257 -0
  54. package/dist/core/cicd.js +1443 -0
  55. package/dist/core/cloud-sync.d.ts +156 -0
  56. package/dist/core/cloud-sync.js +582 -0
  57. package/dist/core/code-review.d.ts +132 -0
  58. package/dist/core/code-review.js +1191 -0
  59. package/dist/core/completion.d.ts +49 -0
  60. package/dist/core/completion.js +384 -0
  61. package/dist/core/context.d.ts +38 -0
  62. package/dist/core/context.js +144 -0
  63. package/dist/core/diff-preview.d.ts +35 -0
  64. package/dist/core/diff-preview.js +173 -0
  65. package/dist/core/doom-loop.d.ts +51 -0
  66. package/dist/core/doom-loop.js +179 -0
  67. package/dist/core/engine.d.ts +183 -0
  68. package/dist/core/engine.js +942 -0
  69. package/dist/core/extended-thinking.d.ts +103 -0
  70. package/dist/core/extended-thinking.js +269 -0
  71. package/dist/core/fallback.d.ts +54 -0
  72. package/dist/core/fallback.js +104 -0
  73. package/dist/core/git-worktree.d.ts +139 -0
  74. package/dist/core/git-worktree.js +614 -0
  75. package/dist/core/headless.d.ts +30 -0
  76. package/dist/core/headless.js +101 -0
  77. package/dist/core/i18n.d.ts +83 -0
  78. package/dist/core/i18n.js +739 -0
  79. package/dist/core/linting.d.ts +129 -0
  80. package/dist/core/linting.js +699 -0
  81. package/dist/core/model-router.d.ts +109 -0
  82. package/dist/core/model-router.js +581 -0
  83. package/dist/core/multi-model.d.ts +243 -0
  84. package/dist/core/multi-model.js +1099 -0
  85. package/dist/core/multi-session.d.ts +144 -0
  86. package/dist/core/multi-session.js +442 -0
  87. package/dist/core/multimodal.d.ts +125 -0
  88. package/dist/core/multimodal.js +286 -0
  89. package/dist/core/observability.d.ts +93 -0
  90. package/dist/core/observability.js +737 -0
  91. package/dist/core/os-sandbox.d.ts +122 -0
  92. package/dist/core/os-sandbox.js +1193 -0
  93. package/dist/core/outcome-grading.d.ts +228 -0
  94. package/dist/core/outcome-grading.js +1123 -0
  95. package/dist/core/output-styles.d.ts +57 -0
  96. package/dist/core/output-styles.js +382 -0
  97. package/dist/core/parallel-agents.d.ts +183 -0
  98. package/dist/core/parallel-agents.js +563 -0
  99. package/dist/core/plugin-bundle.d.ts +236 -0
  100. package/dist/core/plugin-bundle.js +887 -0
  101. package/dist/core/plugin-sdk.d.ts +139 -0
  102. package/dist/core/plugin-sdk.js +273 -0
  103. package/dist/core/prompt-cache.d.ts +163 -0
  104. package/dist/core/prompt-cache.js +599 -0
  105. package/dist/core/sandbox.d.ts +127 -0
  106. package/dist/core/sandbox.js +369 -0
  107. package/dist/core/scheduled-tasks.d.ts +151 -0
  108. package/dist/core/scheduled-tasks.js +502 -0
  109. package/dist/core/security-scanner.d.ts +160 -0
  110. package/dist/core/security-scanner.js +1494 -0
  111. package/dist/core/session.d.ts +83 -0
  112. package/dist/core/session.js +269 -0
  113. package/dist/core/shell-completion.d.ts +51 -0
  114. package/dist/core/shell-completion.js +674 -0
  115. package/dist/core/smart-monitor.d.ts +146 -0
  116. package/dist/core/smart-monitor.js +1199 -0
  117. package/dist/core/spec-driven.d.ts +233 -0
  118. package/dist/core/spec-driven.js +1485 -0
  119. package/dist/core/spending-warnings.d.ts +123 -0
  120. package/dist/core/spending-warnings.js +456 -0
  121. package/dist/core/sub-agent.d.ts +298 -0
  122. package/dist/core/sub-agent.js +1023 -0
  123. package/dist/core/telemetry.d.ts +157 -0
  124. package/dist/core/telemetry.js +412 -0
  125. package/dist/core/terminal-ux.d.ts +134 -0
  126. package/dist/core/terminal-ux.js +649 -0
  127. package/dist/core/testing.d.ts +146 -0
  128. package/dist/core/testing.js +1200 -0
  129. package/dist/core/types.d.ts +439 -0
  130. package/dist/core/types.js +6 -0
  131. package/dist/core/undo-redo.d.ts +112 -0
  132. package/dist/core/undo-redo.js +290 -0
  133. package/dist/core/updater.d.ts +159 -0
  134. package/dist/core/updater.js +608 -0
  135. package/dist/core/vim-mode.d.ts +151 -0
  136. package/dist/core/vim-mode.js +771 -0
  137. package/dist/core/voice.d.ts +137 -0
  138. package/dist/core/voice.js +538 -0
  139. package/dist/core/web-dashboard.d.ts +109 -0
  140. package/dist/core/web-dashboard.js +484 -0
  141. package/dist/hooks/hooks.d.ts +74 -0
  142. package/dist/hooks/hooks.js +160 -0
  143. package/dist/hooks/llm-evaluator.d.ts +165 -0
  144. package/dist/hooks/llm-evaluator.js +560 -0
  145. package/dist/index.d.ts +3 -0
  146. package/dist/index.js +1186 -0
  147. package/dist/lsp/lsp-manager.d.ts +63 -0
  148. package/dist/lsp/lsp-manager.js +351 -0
  149. package/dist/mcp/client.d.ts +133 -0
  150. package/dist/mcp/client.js +684 -0
  151. package/dist/mcp/mcp-apps.d.ts +70 -0
  152. package/dist/mcp/mcp-apps.js +1007 -0
  153. package/dist/tools/bash.d.ts +5 -0
  154. package/dist/tools/bash.js +195 -0
  155. package/dist/tools/browser.d.ts +92 -0
  156. package/dist/tools/browser.js +1570 -0
  157. package/dist/tools/extended.d.ts +6 -0
  158. package/dist/tools/extended.js +191 -0
  159. package/dist/tools/file.d.ts +10 -0
  160. package/dist/tools/file.js +382 -0
  161. package/dist/tools/github.d.ts +389 -0
  162. package/dist/tools/github.js +759 -0
  163. package/dist/tools/index.d.ts +9 -0
  164. package/dist/tools/index.js +40 -0
  165. package/dist/tools/memory.d.ts +6 -0
  166. package/dist/tools/memory.js +197 -0
  167. package/dist/tools/registry.d.ts +29 -0
  168. package/dist/tools/registry.js +64 -0
  169. package/dist/tools/web.d.ts +6 -0
  170. package/dist/tools/web.js +150 -0
  171. package/dist/ui/renderer.d.ts +97 -0
  172. package/dist/ui/renderer.js +279 -0
  173. package/dist/ui/theme.d.ts +27 -0
  174. package/dist/ui/theme.js +106 -0
  175. package/package.json +83 -0
@@ -0,0 +1,1123 @@
1
+ // ============================================================
2
+ // NeuroCLI - Rubric-Based Outcome Grading (GAP-35)
3
+ // Isolated evaluator grades agent output against structured
4
+ // rubrics; revision loop drives agents to meet criteria
5
+ // before delivery. Inspired by Claude Code Managed Agents.
6
+ // No external dependencies — Node.js built-in modules only.
7
+ // ============================================================
8
+ import { resolve, join, extname } from 'path';
9
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
10
+ // ============================================================
11
+ // Constants
12
+ // ============================================================
13
+ const DEFAULT_GRADING_CONFIG = {
14
+ defaultEvaluatorModel: 'anthropic/claude-sonnet-4',
15
+ globalMaxRevisions: 5,
16
+ rubricsDir: '.neuro/rubrics',
17
+ persistHistory: false,
18
+ historyDir: '.neuro/grading-history',
19
+ evaluatorTemperature: 0.1,
20
+ evaluatorTimeoutMs: 60_000,
21
+ };
22
+ /** Characters per token heuristic for rough estimation. */
23
+ const CHARS_PER_TOKEN = 4;
24
+ // ============================================================
25
+ // Built-in Rubric Definitions
26
+ // ============================================================
27
+ function createBuiltinRubrics() {
28
+ return [
29
+ {
30
+ id: 'builtin-code-quality',
31
+ name: 'Code Quality',
32
+ description: 'Evaluates clean code principles: readability, proper naming, absence of code smells, appropriate abstractions, and adherence to SOLID principles.',
33
+ criteria: [
34
+ {
35
+ id: 'cq-readability',
36
+ name: 'Readability',
37
+ description: 'Code is easy to read and understand. Indentation, spacing, and formatting are consistent. Complex logic has explanatory comments.',
38
+ weight: 0.25,
39
+ type: 'scale',
40
+ passingScore: 70,
41
+ },
42
+ {
43
+ id: 'cq-naming',
44
+ name: 'Naming Conventions',
45
+ description: 'Variables, functions, classes, and modules use clear, descriptive names. No abbreviations unless widely understood. No single-letter variables except loop counters.',
46
+ weight: 0.2,
47
+ type: 'scale',
48
+ passingScore: 70,
49
+ },
50
+ {
51
+ id: 'cq-no-smells',
52
+ name: 'No Code Smells',
53
+ description: 'No duplicated code, overly long functions (>50 lines), deeply nested logic (>3 levels), magic numbers, or dead code.',
54
+ weight: 0.25,
55
+ type: 'boolean',
56
+ passingScore: 100,
57
+ },
58
+ {
59
+ id: 'cq-abstractions',
60
+ name: 'Appropriate Abstractions',
61
+ description: 'Abstractions are at the right level. No leaky abstractions. Functions do one thing. Classes have single responsibility.',
62
+ weight: 0.15,
63
+ type: 'scale',
64
+ passingScore: 60,
65
+ },
66
+ {
67
+ id: 'cq-solid',
68
+ name: 'SOLID Principles',
69
+ description: 'Code follows SOLID principles where applicable. Dependencies are injected, not hardcoded. Open for extension, closed for modification.',
70
+ weight: 0.15,
71
+ type: 'scale',
72
+ passingScore: 60,
73
+ },
74
+ ],
75
+ passingThreshold: 70,
76
+ maxRevisions: 3,
77
+ evaluatorModel: 'anthropic/claude-sonnet-4',
78
+ },
79
+ {
80
+ id: 'builtin-security',
81
+ name: 'Security',
82
+ description: 'Evaluates security posture: no vulnerabilities, proper input validation, no hardcoded secrets, safe data handling, and follows OWASP guidelines.',
83
+ criteria: [
84
+ {
85
+ id: 'sec-input-validation',
86
+ name: 'Input Validation',
87
+ description: 'All user inputs are validated, sanitized, and type-checked. No trust of client-side data. Parameterized queries used for databases.',
88
+ weight: 0.3,
89
+ type: 'boolean',
90
+ passingScore: 100,
91
+ },
92
+ {
93
+ id: 'sec-no-secrets',
94
+ name: 'No Hardcoded Secrets',
95
+ description: 'No API keys, passwords, tokens, or private keys in source code. Secrets are loaded from environment variables or secret managers.',
96
+ weight: 0.25,
97
+ type: 'boolean',
98
+ passingScore: 100,
99
+ },
100
+ {
101
+ id: 'sec-vulnerabilities',
102
+ name: 'No Known Vulnerabilities',
103
+ description: 'No SQL injection, XSS, CSRF, path traversal, command injection, or other OWASP Top 10 vulnerabilities.',
104
+ weight: 0.3,
105
+ type: 'boolean',
106
+ passingScore: 100,
107
+ },
108
+ {
109
+ id: 'sec-safe-handling',
110
+ name: 'Safe Data Handling',
111
+ description: 'Sensitive data is encrypted at rest and in transit. PII is not logged. Error messages do not leak internal details.',
112
+ weight: 0.15,
113
+ type: 'scale',
114
+ passingScore: 80,
115
+ },
116
+ ],
117
+ passingThreshold: 90,
118
+ maxRevisions: 3,
119
+ evaluatorModel: 'anthropic/claude-sonnet-4',
120
+ },
121
+ {
122
+ id: 'builtin-test-coverage',
123
+ name: 'Test Coverage',
124
+ description: 'Evaluates test quality and coverage: tests exist, cover edge cases, have good assertions, and follow testing best practices.',
125
+ criteria: [
126
+ {
127
+ id: 'tc-existence',
128
+ name: 'Tests Exist',
129
+ description: 'Unit and/or integration tests are present for the implemented functionality.',
130
+ weight: 0.2,
131
+ type: 'boolean',
132
+ passingScore: 100,
133
+ },
134
+ {
135
+ id: 'tc-edge-cases',
136
+ name: 'Edge Cases Covered',
137
+ description: 'Boundary conditions, empty inputs, null/undefined values, large inputs, and error paths are tested.',
138
+ weight: 0.25,
139
+ type: 'scale',
140
+ passingScore: 70,
141
+ },
142
+ {
143
+ id: 'tc-assertions',
144
+ name: 'Meaningful Assertions',
145
+ description: 'Assertions verify actual behavior, not just that no errors occurred. Each test has clear arrange/act/assert structure.',
146
+ weight: 0.25,
147
+ type: 'scale',
148
+ passingScore: 70,
149
+ },
150
+ {
151
+ id: 'tc-isolation',
152
+ name: 'Test Isolation',
153
+ description: 'Tests are independent and can run in any order. External dependencies are properly mocked or stubbed. No shared mutable state between tests.',
154
+ weight: 0.15,
155
+ type: 'scale',
156
+ passingScore: 60,
157
+ },
158
+ {
159
+ id: 'tc-naming',
160
+ name: 'Test Naming',
161
+ description: 'Test names clearly describe the scenario and expected outcome. Follow consistent naming convention (e.g., should_X_when_Y).',
162
+ weight: 0.15,
163
+ type: 'scale',
164
+ passingScore: 60,
165
+ },
166
+ ],
167
+ passingThreshold: 70,
168
+ maxRevisions: 3,
169
+ evaluatorModel: 'anthropic/claude-sonnet-4',
170
+ },
171
+ {
172
+ id: 'builtin-performance',
173
+ name: 'Performance',
174
+ description: 'Evaluates performance characteristics: no obvious bottlenecks, efficient algorithms, proper data structure choices, and resource awareness.',
175
+ criteria: [
176
+ {
177
+ id: 'perf-algorithms',
178
+ name: 'Efficient Algorithms',
179
+ description: 'Algorithm complexity is appropriate for the problem. No O(n²) where O(n log n) or O(n) suffices. No unnecessary nested loops.',
180
+ weight: 0.3,
181
+ type: 'scale',
182
+ passingScore: 70,
183
+ },
184
+ {
185
+ id: 'perf-data-structures',
186
+ name: 'Appropriate Data Structures',
187
+ description: 'Correct data structures are chosen for the use case. Arrays vs Maps vs Sets are used appropriately. No linear searches where hash lookups apply.',
188
+ weight: 0.25,
189
+ type: 'scale',
190
+ passingScore: 70,
191
+ },
192
+ {
193
+ id: 'perf-no-bottlenecks',
194
+ name: 'No Obvious Bottlenecks',
195
+ description: 'No unnecessary I/O in loops, no synchronous blocking in async contexts, no memory leaks, no excessive object creation.',
196
+ weight: 0.25,
197
+ type: 'boolean',
198
+ passingScore: 100,
199
+ },
200
+ {
201
+ id: 'perf-resource-awareness',
202
+ name: 'Resource Awareness',
203
+ description: 'Connections are pooled, caches used where appropriate, large datasets are streamed rather than loaded entirely, cleanup is handled properly.',
204
+ weight: 0.2,
205
+ type: 'scale',
206
+ passingScore: 60,
207
+ },
208
+ ],
209
+ passingThreshold: 70,
210
+ maxRevisions: 3,
211
+ evaluatorModel: 'anthropic/claude-sonnet-4',
212
+ },
213
+ {
214
+ id: 'builtin-documentation',
215
+ name: 'Documentation',
216
+ description: 'Evaluates documentation quality: code comments, docstrings, README completeness, API documentation, and usage examples.',
217
+ criteria: [
218
+ {
219
+ id: 'doc-comments',
220
+ name: 'Code Comments',
221
+ description: 'Complex logic is explained with inline comments. Comments explain "why", not "what". No commented-out code left behind.',
222
+ weight: 0.2,
223
+ type: 'scale',
224
+ passingScore: 60,
225
+ },
226
+ {
227
+ id: 'doc-docstrings',
228
+ name: 'Docstrings / Type Docs',
229
+ description: 'Public functions, classes, and interfaces have docstrings or TSDoc/JSDoc annotations. Parameters and return values are documented.',
230
+ weight: 0.3,
231
+ type: 'scale',
232
+ passingScore: 70,
233
+ },
234
+ {
235
+ id: 'doc-readme',
236
+ name: 'README / Module Docs',
237
+ description: 'Module or package has a README explaining purpose, installation, usage, and configuration. Examples are provided for common use cases.',
238
+ weight: 0.25,
239
+ type: 'scale',
240
+ passingScore: 60,
241
+ },
242
+ {
243
+ id: 'doc-api-docs',
244
+ name: 'API Documentation',
245
+ description: 'Public APIs are documented with request/response examples. Error codes and edge cases are explained. Authentication is described if applicable.',
246
+ weight: 0.25,
247
+ type: 'scale',
248
+ passingScore: 60,
249
+ },
250
+ ],
251
+ passingThreshold: 65,
252
+ maxRevisions: 3,
253
+ evaluatorModel: 'anthropic/claude-sonnet-4',
254
+ },
255
+ ];
256
+ }
257
+ // ============================================================
258
+ // OutcomeGrader Class
259
+ // ============================================================
260
+ export class OutcomeGrader {
261
+ rubrics = new Map();
262
+ gradingHistory = [];
263
+ apiClient;
264
+ config;
265
+ constructor(apiClient, config) {
266
+ this.apiClient = apiClient ?? null;
267
+ this.config = { ...DEFAULT_GRADING_CONFIG, ...config };
268
+ // Load built-in rubrics
269
+ for (const rubric of createBuiltinRubrics()) {
270
+ this.rubrics.set(rubric.id, rubric);
271
+ }
272
+ }
273
+ // ----------------------------------------------------------
274
+ // Rubric Management
275
+ // ----------------------------------------------------------
276
+ /**
277
+ * Register a rubric definition. Overwrites any existing rubric with the same id.
278
+ */
279
+ defineRubric(rubric) {
280
+ this.validateRubric(rubric);
281
+ this.rubrics.set(rubric.id, { ...rubric });
282
+ }
283
+ /**
284
+ * Return all registered rubrics.
285
+ */
286
+ listRubrics() {
287
+ return Array.from(this.rubrics.values());
288
+ }
289
+ /**
290
+ * Get a single rubric by id, or undefined if not found.
291
+ */
292
+ getRubric(rubricId) {
293
+ return this.rubrics.get(rubricId);
294
+ }
295
+ /**
296
+ * Remove a rubric by id. Returns true if the rubric existed and was removed.
297
+ */
298
+ removeRubric(rubricId) {
299
+ return this.rubrics.delete(rubricId);
300
+ }
301
+ /**
302
+ * Load rubric definitions from JSON files in a directory.
303
+ * Each .json file should contain a single Rubric object or an array of Rubric objects.
304
+ */
305
+ async loadRubrics(dir) {
306
+ const absDir = resolve(dir);
307
+ if (!existsSync(absDir)) {
308
+ mkdirSync(absDir, { recursive: true });
309
+ return;
310
+ }
311
+ const entries = readdirSync(absDir, { withFileTypes: true });
312
+ for (const entry of entries) {
313
+ if (!entry.isFile() || extname(entry.name).toLowerCase() !== '.json') {
314
+ continue;
315
+ }
316
+ const filePath = join(absDir, entry.name);
317
+ try {
318
+ const raw = readFileSync(filePath, 'utf-8');
319
+ const parsed = JSON.parse(raw);
320
+ if (Array.isArray(parsed)) {
321
+ for (const item of parsed) {
322
+ this.defineRubric(item);
323
+ }
324
+ }
325
+ else if (typeof parsed === 'object' && parsed !== null) {
326
+ this.defineRubric(parsed);
327
+ }
328
+ }
329
+ catch {
330
+ // Skip malformed rubric files silently — callers can validate via listRubrics
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * Save a rubric to disk as JSON.
336
+ */
337
+ async saveRubric(rubric, dir) {
338
+ const targetDir = resolve(dir ?? this.config.rubricsDir);
339
+ if (!existsSync(targetDir)) {
340
+ mkdirSync(targetDir, { recursive: true });
341
+ }
342
+ const filePath = join(targetDir, `${rubric.id}.json`);
343
+ writeFileSync(filePath, JSON.stringify(rubric, null, 2), 'utf-8');
344
+ }
345
+ // ----------------------------------------------------------
346
+ // Grading
347
+ // ----------------------------------------------------------
348
+ /**
349
+ * Grade an output string against a rubric.
350
+ * Uses the isolated evaluator (separate LLM call) so that grading
351
+ * is decoupled from agent reasoning context.
352
+ *
353
+ * If no API client is available, performs heuristic-based grading.
354
+ */
355
+ async grade(output, rubricId, context) {
356
+ const rubric = this.rubrics.get(rubricId);
357
+ if (!rubric) {
358
+ throw new Error(`Rubric not found: "${rubricId}". Available rubrics: ${Array.from(this.rubrics.keys()).join(', ')}`);
359
+ }
360
+ const evaluatorModel = rubric.evaluatorModel || this.config.defaultEvaluatorModel;
361
+ let totalTokensUsed = 0;
362
+ const criteriaResults = [];
363
+ if (this.apiClient && typeof this.apiClient.chat === 'function') {
364
+ // Isolated LLM-based evaluation
365
+ const result = await this.evaluateWithLLM(output, rubric, evaluatorModel, context);
366
+ totalTokensUsed = result.tokensUsed;
367
+ criteriaResults.push(...result.criteriaResults);
368
+ }
369
+ else {
370
+ // Heuristic-based fallback when no API client is available
371
+ const result = this.evaluateWithHeuristics(output, rubric);
372
+ criteriaResults.push(...result);
373
+ }
374
+ // Compute weighted overall score
375
+ let overallScore = 0;
376
+ for (let i = 0; i < rubric.criteria.length; i++) {
377
+ const criterion = rubric.criteria[i];
378
+ const criterionResult = criteriaResults.find((cr) => cr.criterionId === criterion.id);
379
+ if (criterionResult) {
380
+ overallScore += criterion.weight * criterionResult.score;
381
+ }
382
+ }
383
+ overallScore = Math.round(overallScore * 100) / 100;
384
+ const passed = overallScore >= rubric.passingThreshold;
385
+ const revisionNeeded = !passed;
386
+ const revisionSuggestions = this.generateRevisionSuggestions(rubric, criteriaResults);
387
+ const feedback = this.synthesizeFeedback(rubric, criteriaResults, overallScore, passed);
388
+ const gradingResult = {
389
+ rubricId,
390
+ overallScore,
391
+ passed,
392
+ criteriaResults,
393
+ feedback,
394
+ revisionNeeded,
395
+ revisionSuggestions,
396
+ evaluatorModel,
397
+ tokensUsed: totalTokensUsed,
398
+ };
399
+ this.gradingHistory.push(gradingResult);
400
+ if (this.config.persistHistory) {
401
+ this.persistGradingResult(gradingResult);
402
+ }
403
+ return gradingResult;
404
+ }
405
+ /**
406
+ * Grade an agent's output and loop revisions until the rubric is satisfied
407
+ * or the maximum number of revisions is reached.
408
+ *
409
+ * The agent receives grading feedback + revision suggestions after each
410
+ * unsuccessful attempt and is asked to revise.
411
+ */
412
+ async gradeWithRevision(agent, prompt, rubricId) {
413
+ const rubric = this.rubrics.get(rubricId);
414
+ if (!rubric) {
415
+ throw new Error(`Rubric not found: "${rubricId}". Available rubrics: ${Array.from(this.rubrics.keys()).join(', ')}`);
416
+ }
417
+ const maxRevisions = Math.min(rubric.maxRevisions, this.config.globalMaxRevisions);
418
+ const revisionHistory = [];
419
+ let currentOutput = '';
420
+ let currentGrade = null;
421
+ // Initial run
422
+ const initialRun = await agent.run(prompt);
423
+ currentOutput = initialRun.output;
424
+ // Grade the initial output
425
+ currentGrade = await this.grade(currentOutput, rubricId, prompt);
426
+ revisionHistory.push({
427
+ revisionNumber: 0,
428
+ output: currentOutput,
429
+ grade: currentGrade,
430
+ timestamp: Date.now(),
431
+ });
432
+ // Revision loop
433
+ let revisionCount = 0;
434
+ while (!currentGrade.passed && revisionCount < maxRevisions) {
435
+ revisionCount++;
436
+ const revisionPrompt = this.buildRevisionPrompt(prompt, currentOutput, currentGrade, rubric, revisionCount, maxRevisions);
437
+ const revisionRun = await agent.run(revisionPrompt);
438
+ currentOutput = revisionRun.output;
439
+ currentGrade = await this.grade(currentOutput, rubricId, prompt);
440
+ revisionHistory.push({
441
+ revisionNumber: revisionCount,
442
+ output: currentOutput,
443
+ grade: currentGrade,
444
+ timestamp: Date.now(),
445
+ });
446
+ }
447
+ return {
448
+ finalOutput: currentOutput,
449
+ finalGrade: currentGrade,
450
+ totalRevisions: revisionCount,
451
+ revisionHistory,
452
+ passed: currentGrade.passed,
453
+ };
454
+ }
455
+ // ----------------------------------------------------------
456
+ // History
457
+ // ----------------------------------------------------------
458
+ /**
459
+ * Return all grading results from this session.
460
+ */
461
+ getGradingHistory() {
462
+ return [...this.gradingHistory];
463
+ }
464
+ /**
465
+ * Clear all grading history from memory.
466
+ */
467
+ clearHistory() {
468
+ this.gradingHistory = [];
469
+ }
470
+ /**
471
+ * Return grading results filtered by rubric id.
472
+ */
473
+ getGradingHistoryByRubric(rubricId) {
474
+ return this.gradingHistory.filter((g) => g.rubricId === rubricId);
475
+ }
476
+ /**
477
+ * Return the most recent grading result, or undefined if none exists.
478
+ */
479
+ getLatestGrading() {
480
+ if (this.gradingHistory.length === 0)
481
+ return undefined;
482
+ return this.gradingHistory[this.gradingHistory.length - 1];
483
+ }
484
+ // ----------------------------------------------------------
485
+ // Composite Rubric Helpers
486
+ // ----------------------------------------------------------
487
+ /**
488
+ * Create a composite rubric that combines multiple criteria from
489
+ * existing rubrics with custom weights.
490
+ *
491
+ * @param id - Unique id for the composite rubric
492
+ * @param name - Human-readable name
493
+ * @param criteria - Array of { rubricId, criterionId, weight } tuples
494
+ * @param passingThreshold - Overall passing threshold (0-100)
495
+ * @param maxRevisions - Maximum revision attempts
496
+ * @param evaluatorModel - Model to use for evaluation
497
+ */
498
+ createCompositeRubric(id, name, criteria, passingThreshold, maxRevisions = 3, evaluatorModel) {
499
+ const compositeCriteria = [];
500
+ for (const item of criteria) {
501
+ const sourceRubric = this.rubrics.get(item.rubricId);
502
+ if (!sourceRubric) {
503
+ throw new Error(`Source rubric not found: "${item.rubricId}"`);
504
+ }
505
+ const sourceCriterion = sourceRubric.criteria.find((c) => c.id === item.criterionId);
506
+ if (!sourceCriterion) {
507
+ throw new Error(`Criterion "${item.criterionId}" not found in rubric "${item.rubricId}"`);
508
+ }
509
+ // Prefix criterion id with source rubric id to avoid collisions
510
+ compositeCriteria.push({
511
+ ...sourceCriterion,
512
+ id: `${item.rubricId}__${item.criterionId}`,
513
+ weight: item.weight,
514
+ });
515
+ }
516
+ const rubric = {
517
+ id,
518
+ name,
519
+ description: `Composite rubric combining criteria from: ${criteria.map((c) => c.rubricId).join(', ')}`,
520
+ criteria: compositeCriteria,
521
+ passingThreshold,
522
+ maxRevisions,
523
+ evaluatorModel: evaluatorModel ?? this.config.defaultEvaluatorModel,
524
+ };
525
+ this.defineRubric(rubric);
526
+ return rubric;
527
+ }
528
+ // ----------------------------------------------------------
529
+ // Private: LLM-Based Evaluation
530
+ // ----------------------------------------------------------
531
+ /**
532
+ * Evaluate output against all criteria in a rubric using an isolated LLM call.
533
+ * The evaluator runs in a separate context window from the agent, ensuring
534
+ * objective assessment.
535
+ */
536
+ async evaluateWithLLM(output, rubric, model, context) {
537
+ const criteriaDescriptions = rubric.criteria
538
+ .map((c) => {
539
+ let typeGuidance = '';
540
+ if (c.type === 'boolean') {
541
+ typeGuidance = 'Score 100 if the criterion is fully met, 0 if it is not met. No partial scores.';
542
+ }
543
+ else if (c.type === 'scale') {
544
+ typeGuidance = `Score on a 0-100 scale. Passing threshold is ${c.passingScore}.`;
545
+ }
546
+ else {
547
+ typeGuidance = 'Provide a descriptive score from 0-100 based on the quality and completeness of the output for this criterion.';
548
+ }
549
+ return `- ID: ${c.id} | Name: ${c.name} | Weight: ${c.weight}\n Description: ${c.description}\n Type: ${c.type} | ${typeGuidance}`;
550
+ })
551
+ .join('\n\n');
552
+ const systemPrompt = `You are an impartial outcome evaluator. Your job is to grade the provided output against a rubric's criteria. You must be objective, evidence-based, and thorough.
553
+
554
+ SCORING RULES:
555
+ - Each criterion gets a score from 0 to 100.
556
+ - For "boolean" criteria: score 100 if fully met, 0 if not met.
557
+ - For "scale" criteria: score proportionally, with the passing threshold as guidance.
558
+ - For "descriptive" criteria: score based on the depth, accuracy, and completeness of the output relative to the criterion description.
559
+ - Provide specific evidence from the output to justify each score.
560
+ - Provide actionable feedback for improvement.
561
+
562
+ RESPONSE FORMAT — you MUST respond with valid JSON matching this structure:
563
+ {
564
+ "criteriaResults": [
565
+ {
566
+ "criterionId": "<criterion id>",
567
+ "score": <0-100>,
568
+ "passed": <true|false>,
569
+ "feedback": "<specific feedback>",
570
+ "evidence": "<what you found in the output>"
571
+ }
572
+ ]
573
+ }
574
+
575
+ Do NOT include any text outside the JSON object.`;
576
+ const contextSection = context
577
+ ? `\n\nCONTEXT (the original task/prompt):\n${context}`
578
+ : '';
579
+ const userPrompt = `RUBRIC: ${rubric.name}
580
+ Passing threshold: ${rubric.passingThreshold}/100
581
+
582
+ CRITERIA:
583
+ ${criteriaDescriptions}
584
+ ${contextSection}
585
+
586
+ OUTPUT TO EVALUATE:
587
+ ${output}
588
+
589
+ Evaluate the output against each criterion. Respond ONLY with the JSON object.`;
590
+ let tokensUsed = 0;
591
+ let criteriaResults = [];
592
+ try {
593
+ const response = await this.apiClient.chat({
594
+ model,
595
+ messages: [
596
+ { role: 'system', content: systemPrompt },
597
+ { role: 'user', content: userPrompt },
598
+ ],
599
+ temperature: this.config.evaluatorTemperature,
600
+ max_tokens: 4096,
601
+ timeout: this.config.evaluatorTimeoutMs,
602
+ });
603
+ if (response.usage) {
604
+ tokensUsed = response.usage.total_tokens;
605
+ }
606
+ else {
607
+ // Rough estimation
608
+ tokensUsed = Math.ceil((systemPrompt.length + userPrompt.length + response.content.length) / CHARS_PER_TOKEN);
609
+ }
610
+ criteriaResults = this.parseEvaluationResponse(response.content, rubric);
611
+ }
612
+ catch (error) {
613
+ // If LLM call fails, fall back to heuristic grading
614
+ criteriaResults = this.evaluateWithHeuristics(output, rubric);
615
+ }
616
+ // Ensure every criterion has a result
617
+ for (const criterion of rubric.criteria) {
618
+ const existing = criteriaResults.find((cr) => cr.criterionId === criterion.id);
619
+ if (!existing) {
620
+ criteriaResults.push({
621
+ criterionId: criterion.id,
622
+ score: 0,
623
+ passed: false,
624
+ feedback: `Evaluation failed for criterion "${criterion.name}". Defaulting to 0.`,
625
+ evidence: 'No evaluation evidence available.',
626
+ });
627
+ }
628
+ }
629
+ return { criteriaResults, tokensUsed };
630
+ }
631
+ /**
632
+ * Parse the LLM's JSON evaluation response into structured CriterionResult objects.
633
+ * Handles malformed responses gracefully.
634
+ */
635
+ parseEvaluationResponse(responseContent, rubric) {
636
+ const results = [];
637
+ // Extract JSON from response (handle markdown code blocks)
638
+ let jsonStr = responseContent.trim();
639
+ const jsonBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
640
+ if (jsonBlockMatch) {
641
+ jsonStr = jsonBlockMatch[1].trim();
642
+ }
643
+ // Try to find JSON object in the response
644
+ const braceStart = jsonStr.indexOf('{');
645
+ const braceEnd = jsonStr.lastIndexOf('}');
646
+ if (braceStart === -1 || braceEnd === -1) {
647
+ return results;
648
+ }
649
+ jsonStr = jsonStr.slice(braceStart, braceEnd + 1);
650
+ let parsed;
651
+ try {
652
+ parsed = JSON.parse(jsonStr);
653
+ }
654
+ catch {
655
+ return results;
656
+ }
657
+ if (!parsed || !Array.isArray(parsed.criteriaResults)) {
658
+ return results;
659
+ }
660
+ const validCriterionIds = new Set(rubric.criteria.map((c) => c.id));
661
+ for (const item of parsed.criteriaResults) {
662
+ if (!item.criterionId || !validCriterionIds.has(item.criterionId)) {
663
+ continue;
664
+ }
665
+ const criterion = rubric.criteria.find((c) => c.id === item.criterionId);
666
+ let score = typeof item.score === 'number' ? item.score : 0;
667
+ score = Math.max(0, Math.min(100, Math.round(score)));
668
+ let passed;
669
+ if (criterion.type === 'boolean') {
670
+ passed = score >= 100;
671
+ }
672
+ else {
673
+ passed = score >= criterion.passingScore;
674
+ }
675
+ results.push({
676
+ criterionId: item.criterionId,
677
+ score,
678
+ passed,
679
+ feedback: typeof item.feedback === 'string' ? item.feedback : '',
680
+ evidence: typeof item.evidence === 'string' ? item.evidence : '',
681
+ });
682
+ }
683
+ return results;
684
+ }
685
+ // ----------------------------------------------------------
686
+ // Private: Heuristic-Based Evaluation (fallback)
687
+ // ----------------------------------------------------------
688
+ /**
689
+ * Evaluate output using heuristic pattern matching when no LLM is available.
690
+ * Provides basic quality signals based on code/text analysis.
691
+ */
692
+ evaluateWithHeuristics(output, rubric) {
693
+ const results = [];
694
+ for (const criterion of rubric.criteria) {
695
+ const result = this.evaluateCriterionHeuristic(output, criterion, rubric);
696
+ results.push(result);
697
+ }
698
+ return results;
699
+ }
700
+ /**
701
+ * Apply heuristic scoring for a single criterion based on its type and name.
702
+ */
703
+ evaluateCriterionHeuristic(output, criterion, rubric) {
704
+ const outputLines = output.split('\n');
705
+ const outputLen = output.length;
706
+ const nonEmptyLines = outputLines.filter((l) => l.trim().length > 0).length;
707
+ // Detect if output looks like code
708
+ const codeIndicators = [
709
+ /\bfunction\b/, /\bclass\b/, /\bconst\b/, /\blet\b/, /\bvar\b/,
710
+ /\bimport\b/, /\bexport\b/, /\breturn\b/, /\bif\s*\(/, /\bfor\s*\(/,
711
+ ];
712
+ const isCode = codeIndicators.some((pat) => pat.test(output));
713
+ // Detect if output has tests
714
+ const testIndicators = [
715
+ /\bdescribe\s*\(/, /\bit\s*\(/, /\btest\s*\(/, /\bexpect\s*\(/,
716
+ /\bassert\b/, /\bshould\b/, /\bbeforeEach\b/, /\bafterEach\b/,
717
+ ];
718
+ const hasTests = testIndicators.some((pat) => pat.test(output));
719
+ // Detect documentation patterns
720
+ const docIndicators = [
721
+ /\/\*\*[\s\S]*?\*\//, /\/\/\s*.+/, /#{1,6}\s+.+/, /\*\*[^*]+\*\*/,
722
+ /```[\s\S]*?```/,
723
+ ];
724
+ const hasDocs = docIndicators.some((pat) => pat.test(output));
725
+ // Detect security anti-patterns
726
+ const securitySmells = [
727
+ /eval\s*\(/, /innerHTML\s*=/, /document\.write\s*\(/,
728
+ /\bpassword\s*=\s*['"]/, /\bapi[_-]?key\s*=\s*['"]/,
729
+ /\bsecret\s*=\s*['"]/, /SELECT\s+.*\s+FROM\s+/i,
730
+ ];
731
+ const hasSecurityIssues = securitySmells.some((pat) => pat.test(output));
732
+ // Detect performance anti-patterns
733
+ const perfSmells = [
734
+ /for\s*\(.*await\b/, /\.forEach\s*\(.*async/,
735
+ /while\s*\(true\)/, /new Array\(\d{4,}\)/,
736
+ ];
737
+ const hasPerfIssues = perfSmells.some((pat) => pat.test(output));
738
+ let score = 50; // baseline
739
+ let passed = false;
740
+ let feedback = '';
741
+ let evidence = '';
742
+ // Heuristic scoring based on rubric type and criterion name/id
743
+ const nameLower = criterion.name.toLowerCase();
744
+ const idLower = criterion.id.toLowerCase();
745
+ if (idLower.includes('security') || nameLower.includes('security') || nameLower.includes('vulnerabilit') || nameLower.includes('secret') || nameLower.includes('input validation')) {
746
+ if (hasSecurityIssues) {
747
+ score = 20;
748
+ feedback = 'Security anti-patterns detected in the output (e.g., eval, hardcoded secrets, SQL concatenation).';
749
+ evidence = 'Pattern match found security-sensitive constructs.';
750
+ }
751
+ else {
752
+ score = 80;
753
+ feedback = 'No obvious security anti-patterns detected via heuristic scan.';
754
+ evidence = 'Heuristic scan did not find known security smell patterns.';
755
+ }
756
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
757
+ }
758
+ else if (idLower.includes('test') || nameLower.includes('test') || nameLower.includes('coverage') || nameLower.includes('edge case') || nameLower.includes('assertion')) {
759
+ if (hasTests) {
760
+ const testCount = (output.match(/\b(it|test)\s*\(/g) || []).length;
761
+ score = Math.min(100, 50 + testCount * 10);
762
+ feedback = `Found ${testCount} test case(s). Test structure detected.`;
763
+ evidence = `Heuristic detected ${testCount} test() or it() calls.`;
764
+ }
765
+ else {
766
+ score = 15;
767
+ feedback = 'No test patterns detected in the output.';
768
+ evidence = 'Heuristic scan found no describe/test/it/expect patterns.';
769
+ }
770
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
771
+ }
772
+ else if (idLower.includes('doc') || nameLower.includes('doc') || nameLower.includes('comment') || nameLower.includes('readme')) {
773
+ if (hasDocs) {
774
+ const docCommentCount = (output.match(/\/\*\*[\s\S]*?\*\//g) || []).length;
775
+ const inlineCommentCount = (output.match(/\/\/\s*.+/g) || []).length;
776
+ score = Math.min(100, 40 + docCommentCount * 15 + inlineCommentCount * 5);
777
+ feedback = `Found ${docCommentCount} doc comment(s) and ${inlineCommentCount} inline comment(s).`;
778
+ evidence = `Heuristic detected documentation patterns: ${docCommentCount} doc blocks, ${inlineCommentCount} inline comments.`;
779
+ }
780
+ else {
781
+ score = 20;
782
+ feedback = 'No documentation patterns detected.';
783
+ evidence = 'Heuristic scan found no doc comments, inline comments, or markdown headers.';
784
+ }
785
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
786
+ }
787
+ else if (idLower.includes('perf') || nameLower.includes('perform') || nameLower.includes('bottleneck') || nameLower.includes('algorithm') || nameLower.includes('data structure')) {
788
+ if (hasPerfIssues) {
789
+ score = 25;
790
+ feedback = 'Performance anti-patterns detected (e.g., await in loops, unbounded allocations).';
791
+ evidence = 'Pattern match found performance-sensitive constructs.';
792
+ }
793
+ else if (isCode && nonEmptyLines > 10) {
794
+ score = 70;
795
+ feedback = 'No obvious performance anti-patterns detected. Output appears to have reasonable structure.';
796
+ evidence = 'Heuristic scan found no performance smell patterns.';
797
+ }
798
+ else {
799
+ score = 60;
800
+ feedback = 'Limited code to evaluate for performance. No obvious issues detected.';
801
+ evidence = 'Output is too short or not code-like for thorough performance heuristic evaluation.';
802
+ }
803
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
804
+ }
805
+ else if (idLower.includes('readab') || nameLower.includes('readab') || idLower.includes('naming') || nameLower.includes('naming')) {
806
+ if (isCode) {
807
+ const avgLineLen = outputLen / Math.max(nonEmptyLines, 1);
808
+ const longLines = outputLines.filter((l) => l.length > 120).length;
809
+ const lineLenPenalty = longLines * 3;
810
+ const veryShortVars = (output.match(/\b[a-z]\b\s*=/g) || []).length;
811
+ const shortVarPenalty = veryShortVars * 5;
812
+ score = Math.max(0, Math.min(100, 70 - lineLenPenalty - shortVarPenalty));
813
+ feedback = score >= 70
814
+ ? 'Code appears reasonably readable. Line lengths and naming are acceptable.'
815
+ : 'Readability could be improved. Some lines are too long or variable names are too short.';
816
+ evidence = `Average line length: ${Math.round(avgLineLen)}. Long lines (>120 chars): ${longLines}. Single-letter variables: ${veryShortVars}.`;
817
+ }
818
+ else {
819
+ score = 60;
820
+ feedback = 'Output is not code; readability heuristic is less applicable.';
821
+ evidence = 'Non-code output detected; readability assessment based on paragraph structure.';
822
+ }
823
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
824
+ }
825
+ else if (idLower.includes('smell') || nameLower.includes('smell') || idLower.includes('solid') || nameLower.includes('solid') || idLower.includes('abstract') || nameLower.includes('abstract')) {
826
+ if (isCode) {
827
+ const duplicateLines = this.countDuplicateLines(outputLines);
828
+ const deepNesting = this.maxNestingDepth(output);
829
+ const dupPenalty = Math.min(30, duplicateLines * 5);
830
+ const nestPenalty = Math.min(20, Math.max(0, deepNesting - 3) * 10);
831
+ score = Math.max(0, Math.min(100, 75 - dupPenalty - nestPenalty));
832
+ feedback = score >= 70
833
+ ? 'No significant code smells detected heuristically.'
834
+ : 'Potential code smells: duplicated lines or excessive nesting depth.';
835
+ evidence = `Duplicate non-empty lines: ${duplicateLines}. Max nesting depth: ${deepNesting}.`;
836
+ }
837
+ else {
838
+ score = 60;
839
+ feedback = 'Output is not code; code smell heuristic is less applicable.';
840
+ evidence = 'Non-code output detected; limited heuristic evaluation possible.';
841
+ }
842
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
843
+ }
844
+ else {
845
+ // Generic heuristic for any criterion
846
+ if (outputLen < 50) {
847
+ score = 20;
848
+ feedback = 'Output is very short; insufficient to evaluate this criterion thoroughly.';
849
+ evidence = `Output is only ${outputLen} characters long.`;
850
+ }
851
+ else if (outputLen < 200) {
852
+ score = 45;
853
+ feedback = 'Output is short; limited evidence available for evaluation.';
854
+ evidence = `Output is ${outputLen} characters with ${nonEmptyLines} non-empty lines.`;
855
+ }
856
+ else {
857
+ score = 65;
858
+ feedback = 'Output has reasonable length. Heuristic evaluation provides limited insight without LLM evaluator.';
859
+ evidence = `Output is ${outputLen} characters with ${nonEmptyLines} non-empty lines.`;
860
+ }
861
+ passed = criterion.type === 'boolean' ? score >= 100 : score >= criterion.passingScore;
862
+ }
863
+ return {
864
+ criterionId: criterion.id,
865
+ score,
866
+ passed,
867
+ feedback,
868
+ evidence,
869
+ };
870
+ }
871
+ // ----------------------------------------------------------
872
+ // Private: Revision Loop Helpers
873
+ // ----------------------------------------------------------
874
+ /**
875
+ * Build a prompt that asks the agent to revise its output based on grading feedback.
876
+ */
877
+ buildRevisionPrompt(originalPrompt, currentOutput, grade, rubric, revisionNumber, maxRevisions) {
878
+ const failedCriteria = grade.criteriaResults.filter((cr) => !cr.passed);
879
+ const failedDetails = failedCriteria
880
+ .map((cr) => {
881
+ const criterion = rubric.criteria.find((c) => c.id === cr.criterionId);
882
+ return ` - ${criterion?.name ?? cr.criterionId}: Score ${cr.score}/100 (needed ${criterion?.passingScore ?? 'N/A'})
883
+ Feedback: ${cr.feedback}
884
+ Evidence: ${cr.evidence}`;
885
+ })
886
+ .join('\n');
887
+ return `You previously produced output for this task, but it did not pass the "${rubric.name}" rubric (score: ${grade.overallScore}/${rubric.passingThreshold} needed).
888
+
889
+ ORIGINAL TASK:
890
+ ${originalPrompt}
891
+
892
+ YOUR PREVIOUS OUTPUT:
893
+ ${currentOutput}
894
+
895
+ GRADING RESULTS (Revision ${revisionNumber} of ${maxRevisions}):
896
+ Overall score: ${grade.overallScore}/100 (passing: ${rubric.passingThreshold})
897
+
898
+ FAILED CRITERIA:
899
+ ${failedDetails}
900
+
901
+ REVISION SUGGESTIONS:
902
+ ${grade.revisionSuggestions.map((s, i) => `${i + 1}. ${s}`).join('\n')}
903
+
904
+ Please revise your output to address the failed criteria. Focus specifically on the areas where you scored below the passing threshold. Produce a complete, revised output that addresses all the feedback.`;
905
+ }
906
+ /**
907
+ * Generate specific, actionable revision suggestions based on failed criteria.
908
+ */
909
+ generateRevisionSuggestions(rubric, criteriaResults) {
910
+ const suggestions = [];
911
+ for (const result of criteriaResults) {
912
+ if (result.passed)
913
+ continue;
914
+ const criterion = rubric.criteria.find((c) => c.id === result.criterionId);
915
+ if (!criterion)
916
+ continue;
917
+ const gap = criterion.passingScore - result.score;
918
+ if (criterion.type === 'boolean' && !result.passed) {
919
+ suggestions.push(`CRITICAL: "${criterion.name}" is a pass/fail criterion that is currently failing. ${criterion.description}. You MUST address this fully.`);
920
+ }
921
+ else if (criterion.type === 'scale') {
922
+ suggestions.push(`Improve "${criterion.name}" by at least ${gap} points. Current score: ${result.score}/${criterion.passingScore} needed. Focus: ${result.feedback || criterion.description}`);
923
+ }
924
+ else {
925
+ suggestions.push(`Enhance "${criterion.name}": ${result.feedback || criterion.description}`);
926
+ }
927
+ }
928
+ // Prioritize boolean failures first
929
+ suggestions.sort((a, b) => {
930
+ const aCrit = a.startsWith('CRITICAL') ? 0 : 1;
931
+ const bCrit = b.startsWith('CRITICAL') ? 0 : 1;
932
+ return aCrit - bCrit;
933
+ });
934
+ return suggestions;
935
+ }
936
+ /**
937
+ * Synthesize overall feedback from criterion results.
938
+ */
939
+ synthesizeFeedback(rubric, criteriaResults, overallScore, passed) {
940
+ const passedCount = criteriaResults.filter((cr) => cr.passed).length;
941
+ const totalCount = criteriaResults.length;
942
+ const status = passed ? 'PASSED' : 'FAILED';
943
+ let feedback = `Rubric "${rubric.name}" ${status}. Overall score: ${overallScore}/100 (threshold: ${rubric.passingThreshold}). ${passedCount}/${totalCount} criteria passed.`;
944
+ if (!passed) {
945
+ const failedNames = criteriaResults
946
+ .filter((cr) => !cr.passed)
947
+ .map((cr) => {
948
+ const c = rubric.criteria.find((crit) => crit.id === cr.criterionId);
949
+ return c?.name ?? cr.criterionId;
950
+ });
951
+ feedback += ` Failed criteria: ${failedNames.join(', ')}.`;
952
+ }
953
+ // Add per-criterion summary
954
+ feedback += '\n\nCriterion breakdown:';
955
+ for (const cr of criteriaResults) {
956
+ const c = rubric.criteria.find((crit) => crit.id === cr.criterionId);
957
+ const statusIcon = cr.passed ? '✓' : '✗';
958
+ feedback += `\n ${statusIcon} ${c?.name ?? cr.criterionId}: ${cr.score}/100${cr.feedback ? ` — ${cr.feedback}` : ''}`;
959
+ }
960
+ return feedback;
961
+ }
962
+ // ----------------------------------------------------------
963
+ // Private: Rubric Validation
964
+ // ----------------------------------------------------------
965
+ /**
966
+ * Validate that a rubric is well-formed.
967
+ * Throws descriptive errors for structural problems.
968
+ */
969
+ validateRubric(rubric) {
970
+ if (!rubric.id || typeof rubric.id !== 'string') {
971
+ throw new Error('Rubric must have a non-empty string id.');
972
+ }
973
+ if (!rubric.name || typeof rubric.name !== 'string') {
974
+ throw new Error('Rubric must have a non-empty string name.');
975
+ }
976
+ if (!Array.isArray(rubric.criteria) || rubric.criteria.length === 0) {
977
+ throw new Error(`Rubric "${rubric.id}" must have at least one criterion.`);
978
+ }
979
+ const weightSum = rubric.criteria.reduce((sum, c) => sum + c.weight, 0);
980
+ if (Math.abs(weightSum - 1) > 0.01) {
981
+ throw new Error(`Rubric "${rubric.id}" criterion weights must sum to 1.0. Current sum: ${weightSum.toFixed(4)}`);
982
+ }
983
+ const ids = new Set();
984
+ for (const criterion of rubric.criteria) {
985
+ if (!criterion.id || typeof criterion.id !== 'string') {
986
+ throw new Error(`Rubric "${rubric.id}" has a criterion with a missing or invalid id.`);
987
+ }
988
+ if (ids.has(criterion.id)) {
989
+ throw new Error(`Rubric "${rubric.id}" has duplicate criterion id: "${criterion.id}".`);
990
+ }
991
+ ids.add(criterion.id);
992
+ if (criterion.weight < 0 || criterion.weight > 1) {
993
+ throw new Error(`Criterion "${criterion.id}" in rubric "${rubric.id}" has weight outside [0, 1]: ${criterion.weight}`);
994
+ }
995
+ if (!['boolean', 'scale', 'descriptive'].includes(criterion.type)) {
996
+ throw new Error(`Criterion "${criterion.id}" in rubric "${rubric.id}" has invalid type: "${criterion.type}". Must be "boolean", "scale", or "descriptive".`);
997
+ }
998
+ if (criterion.passingScore < 0 || criterion.passingScore > 100) {
999
+ throw new Error(`Criterion "${criterion.id}" in rubric "${rubric.id}" has passingScore outside [0, 100]: ${criterion.passingScore}`);
1000
+ }
1001
+ }
1002
+ if (rubric.passingThreshold < 0 || rubric.passingThreshold > 100) {
1003
+ throw new Error(`Rubric "${rubric.id}" passingThreshold must be between 0 and 100. Got: ${rubric.passingThreshold}`);
1004
+ }
1005
+ if (rubric.maxRevisions < 0 || !Number.isInteger(rubric.maxRevisions)) {
1006
+ throw new Error(`Rubric "${rubric.id}" maxRevisions must be a non-negative integer. Got: ${rubric.maxRevisions}`);
1007
+ }
1008
+ }
1009
+ // ----------------------------------------------------------
1010
+ // Private: Utility Methods
1011
+ // ----------------------------------------------------------
1012
+ /**
1013
+ * Count duplicate non-empty, non-trivial lines in the output.
1014
+ */
1015
+ countDuplicateLines(lines) {
1016
+ const normalized = lines
1017
+ .map((l) => l.trim())
1018
+ .filter((l) => l.length > 5); // ignore short/trivial lines
1019
+ const seen = new Map();
1020
+ let duplicates = 0;
1021
+ for (const line of normalized) {
1022
+ const count = seen.get(line) ?? 0;
1023
+ if (count === 1)
1024
+ duplicates++; // count second occurrence
1025
+ seen.set(line, count + 1);
1026
+ }
1027
+ return duplicates;
1028
+ }
1029
+ /**
1030
+ * Estimate maximum nesting depth of code by counting brace/paren indent levels.
1031
+ */
1032
+ maxNestingDepth(code) {
1033
+ let depth = 0;
1034
+ let maxDepth = 0;
1035
+ let inString = null;
1036
+ for (let i = 0; i < code.length; i++) {
1037
+ const ch = code[i];
1038
+ // Simple string tracking to avoid counting braces inside strings
1039
+ if (inString) {
1040
+ if (ch === inString && code[i - 1] !== '\\') {
1041
+ inString = null;
1042
+ }
1043
+ continue;
1044
+ }
1045
+ if (ch === '"' || ch === "'" || ch === '`') {
1046
+ inString = ch;
1047
+ continue;
1048
+ }
1049
+ if (ch === '{' || ch === '(' || ch === '[') {
1050
+ depth++;
1051
+ if (depth > maxDepth)
1052
+ maxDepth = depth;
1053
+ }
1054
+ else if (ch === '}' || ch === ')' || ch === ']') {
1055
+ depth = Math.max(0, depth - 1);
1056
+ }
1057
+ }
1058
+ return maxDepth;
1059
+ }
1060
+ /**
1061
+ * Persist a grading result to disk for historical analysis.
1062
+ */
1063
+ persistGradingResult(result) {
1064
+ const historyDir = resolve(this.config.historyDir);
1065
+ if (!existsSync(historyDir)) {
1066
+ mkdirSync(historyDir, { recursive: true });
1067
+ }
1068
+ const timestamp = Date.now();
1069
+ const filename = `${result.rubricId}_${timestamp}.json`;
1070
+ const filePath = join(historyDir, filename);
1071
+ writeFileSync(filePath, JSON.stringify(result, null, 2), 'utf-8');
1072
+ }
1073
+ }
1074
+ // ============================================================
1075
+ // Factory & Convenience Functions
1076
+ // ============================================================
1077
+ /**
1078
+ * Create a new OutcomeGrader with optional API client and configuration.
1079
+ */
1080
+ export function createOutcomeGrader(apiClient, config) {
1081
+ return new OutcomeGrader(apiClient, config);
1082
+ }
1083
+ /**
1084
+ * Create a quick rubric with a simpler API.
1085
+ * Useful for one-off grading tasks.
1086
+ */
1087
+ export function createQuickRubric(id, name, criteria, passingThreshold = 70, maxRevisions = 3, evaluatorModel = 'anthropic/claude-sonnet-4') {
1088
+ const n = criteria.length;
1089
+ const equalWeight = Math.round((1 / n) * 1000) / 1000;
1090
+ // Distribute weights equally, adjust last to ensure sum === 1
1091
+ const weights = criteria.map((_, i) => {
1092
+ if (i < n - 1)
1093
+ return equalWeight;
1094
+ const sumSoFar = equalWeight * (n - 1);
1095
+ return Math.round((1 - sumSoFar) * 1000) / 1000;
1096
+ });
1097
+ return {
1098
+ id,
1099
+ name,
1100
+ description: `Quick rubric: ${name}`,
1101
+ criteria: criteria.map((c, i) => ({
1102
+ id: `${id}-c${i + 1}`,
1103
+ name: c.name,
1104
+ description: c.description,
1105
+ weight: weights[i],
1106
+ type: c.type,
1107
+ passingScore: c.passingScore ?? (c.type === 'boolean' ? 100 : 70),
1108
+ })),
1109
+ passingThreshold,
1110
+ maxRevisions,
1111
+ evaluatorModel,
1112
+ };
1113
+ }
1114
+ /**
1115
+ * Grade output against a rubric in a single call without
1116
+ * managing an OutcomeGrader instance.
1117
+ */
1118
+ export async function quickGrade(output, rubric, apiClient, config) {
1119
+ const grader = new OutcomeGrader(apiClient, config);
1120
+ grader.defineRubric(rubric);
1121
+ return grader.grade(output, rubric.id);
1122
+ }
1123
+ //# sourceMappingURL=outcome-grading.js.map