bingocode 1.1.182 → 1.1.184

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.
@@ -1,15 +1,55 @@
1
+ /**
2
+ * Goal Evaluator v2 — environment-aware assessment for goal progress.
3
+ *
4
+ * Replaces the text-only evaluator with a 3-tier system:
5
+ * 1. Rule Engine (deterministic checks — file existence, test output, etc.)
6
+ * 2. Semantic Evaluation (Haiku-level — interprets evidence against goal)
7
+ * 3. Final Verification (main model — for ambiguous cases)
8
+ *
9
+ * Still supports the EVAL block format for agent self-reporting, but now
10
+ * also reads actual tool results, file diffs, and system state to verify
11
+ * progress independently of what the agent claims.
12
+ */
13
+
1
14
  import Anthropic from '@anthropic-ai/sdk'
2
15
  import type { MessageType } from '../components/messages.js'
16
+ import type {
17
+ EvaluationContext,
18
+ EvalResult,
19
+ EvalEvidence,
20
+ RuleCheck,
21
+ ToolResult,
22
+ FileDiff,
23
+ TestOutput,
24
+ CompileOutput,
25
+ GitStatus,
26
+ ErrorLog,
27
+ } from '../types/goal.js'
28
+ import type { SubGoal, GoalDag } from '../types/goal.js'
29
+ import { getGoalState } from './goalStore.js'
30
+ import type { GoalState } from '../types/goal.js'
31
+
32
+ // ============================================================================
33
+ // Model constants
34
+ // ============================================================================
3
35
 
4
36
  export const GOAL_EVALUATOR_MODEL = 'claude-haiku-4-5'
37
+ const EVAL_MAX_TOKENS = 512
38
+
39
+ // ============================================================================
40
+ // Client factory
41
+ // ============================================================================
5
42
 
6
- export type GoalEvalResult = {
7
- satisfied: boolean
8
- reason: string
9
- gap: string | null
43
+ function createClient(): Anthropic {
44
+ return new Anthropic({
45
+ baseURL: process.env.ANTHROPIC_BASE_URL ?? undefined,
46
+ apiKey: process.env.ANTHROPIC_API_KEY ?? undefined,
47
+ })
10
48
  }
11
49
 
12
- // --- EVAL Block parser for structured evaluation ---
50
+ // ============================================================================
51
+ // EVAL Block Parser (retained from v1)
52
+ // ============================================================================
13
53
 
14
54
  type EvalBlock = {
15
55
  metric: string
@@ -17,32 +57,16 @@ type EvalBlock = {
17
57
  passed: boolean
18
58
  }
19
59
 
20
- /**
21
- * Parse markdown text for structured > EVAL: lines.
22
- *
23
- * Accepted actor formats:
24
- * > EVAL: <metric>: <value> / <target> → ✓ or ✗
25
- * > EVAL: <metric>: <value> / <target> -> PASS
26
- * > EVAL: <metric>: <value> / <target> => true
27
- *
28
- * Supports ASCII and Unicode arrow/check/cross variants for maximum compatibility.
29
- */
60
+ const arrow = /(?:→|->|=>)/g.source
61
+ const pass = /(?:✓|✔|PASS|pass|Y\b|true|yes|1)/g.source
62
+ const fail = /(?:✗|✘|FAIL|fail|N\b|false|no|0)/g.source
63
+ const full = new RegExp(
64
+ `EVAL:\\s*(.+?):\\s*(.+?)\\s*(?:${arrow}|)\\s*(${pass}|${fail})`,
65
+ 'g',
66
+ )
67
+
30
68
  function parseEvalBlocks(text: string): EvalBlock[] {
31
69
  const blocks: EvalBlock[] = []
32
-
33
- // Build one combined pattern: capture metric + valuetarget + pass/fail signal.
34
- // Arrow variants: → (U+2192), -> (ASCII), => (ASCII)
35
- // Pass variants: ✓ (U+2713), ✔ (U+2714), PASS (case-insensitive), Y, true, yes, 1
36
- // Fail variants: ✗ (U+2717), ✘ (U+2718), FAIL (case-insensitive), N, false, no, 0
37
- // NOTE: Removed the requirement for ">" prefix to allow EVAL blocks anywhere in text
38
- const arrow = /(?:→|->|=>)/g.source
39
- const pass = /(?:✓|✔|PASS|pass|Y\b|true|yes|1)/g.source
40
- const fail = /(?:✗|✘|FAIL|fail|N\b|false|no|0)/g.source
41
- const full = new RegExp(
42
- `EVAL:\\s*(.+?):\\s*(.+?)\\s*(?:${arrow}|)\\s*(${pass}|${fail})`,
43
- 'g',
44
- )
45
-
46
70
  let match: RegExpExecArray | null
47
71
  while ((match = full.exec(text)) !== null) {
48
72
  const [, metric, valueTarget, signal] = match
@@ -52,12 +76,10 @@ function parseEvalBlocks(text: string): EvalBlock[] {
52
76
  return blocks
53
77
  }
54
78
 
55
- /** Determine if all metrics pass — enabling early termination. */
56
79
  function allMetricsPassing(blocks: EvalBlock[]): boolean {
57
80
  return blocks.length > 0 && blocks.every(b => b.passed)
58
81
  }
59
82
 
60
- /** Extract structured EVAL summary from parsed blocks for consumption by evaluator model. */
61
83
  function evalSummary(blocks: EvalBlock[]): string {
62
84
  if (blocks.length === 0) return '(no EVAL blocks found)'
63
85
  const passed = blocks.filter(b => b.passed).length
@@ -67,21 +89,94 @@ function evalSummary(blocks: EvalBlock[]): string {
67
89
  ].join('\n')
68
90
  }
69
91
 
70
- // --- Core evaluator ---
92
+ // ============================================================================
93
+ // Core evaluation
94
+ // ============================================================================
71
95
 
72
96
  /**
73
- * Optimized goal evaluator.
97
+ * Evaluate goal progress with environment-aware assessment.
74
98
  *
75
99
  * Strategy:
76
- * 1. Regex-parse EVAL blocks from recent assistant text. If all metrics
77
- * pass → short-circuit satisfied without calling evaluator model.
78
- * 2. Feed pre-parsed EVAL summary to Haiku-4.5 for fallback evaluation.
100
+ * 1. Parse EVAL blocks from recent assistant output (fast, no API call)
101
+ * 2. If all metrics pass → short-circuit satisfied
102
+ * 3. Otherwise, build evaluation context and run rule checks
103
+ * 4. If still ambiguous, call Haiku for semantic evaluation
104
+ * 5. For critical goals, optionally escalate to main model
79
105
  */
80
106
  export async function evaluateGoal(
81
107
  goalCondition: string,
82
108
  messages: MessageType[],
83
- ): Promise<GoalEvalResult> {
84
- const recentAssistantTexts = messages
109
+ subGoal?: SubGoal,
110
+ ): Promise<EvalResult> {
111
+ // Extract recent assistant messages for EVAL parsing
112
+ const recentAssistantTexts = extractAssistantTexts(messages)
113
+
114
+ // Phase 1: Parse EVAL blocks from agent self-reports
115
+ const evalBlocks = parseEvalBlocks(recentAssistantTexts)
116
+
117
+ // Short-circuit: all metrics pass → goal satisfied
118
+ if (evalBlocks.length > 0 && allMetricsPassing(evalBlocks)) {
119
+ return {
120
+ satisfied: true,
121
+ level: 'rule',
122
+ reason: `all ${evalBlocks.length} EVAL metrics satisfied`,
123
+ gap: null,
124
+ evidence: evalBlocks.map(b => ({
125
+ source: 'agent-output',
126
+ finding: `${b.metric}: ${b.valueTarget}`,
127
+ relevanceScore: 1.0,
128
+ })),
129
+ }
130
+ }
131
+
132
+ // Phase 2: Build environment context
133
+ const ctx = await buildEvaluationContext(messages)
134
+ const evidenceFromEnv = gatherEvidence(ctx)
135
+
136
+ // Phase 3: Check if there's enough evidence to decide
137
+ if (evidenceFromEnv.length > 0) {
138
+ // Has the agent made concrete progress? Check key signals.
139
+ const hasConcreteProgress = checkConcreteProgress(evidenceFromEnv, goalCondition)
140
+ if (hasConcreteProgress.satisfied) {
141
+ return {
142
+ satisfied: true,
143
+ level: 'rule',
144
+ reason: 'Concrete progress detected from environment',
145
+ gap: null,
146
+ evidence: evidenceFromEnv,
147
+ }
148
+ }
149
+ }
150
+
151
+ // Phase 4: Semantic evaluation with Haiku
152
+ const evalInput = buildEvalInput(goalCondition, evalBlocks, ctx, evidenceFromEnv, messages)
153
+ const semanticResult = await callHaiku(goalCondition, evalInput)
154
+
155
+ // If Haiku is confident either way, trust it
156
+ if (semanticResult.satisfied || (!semanticResult.satisfied && evalBlocks.length === 0)) {
157
+ return {
158
+ ...semanticResult,
159
+ level: 'semantic',
160
+ evidence: [...evidenceFromEnv, ...semanticResult.evidence],
161
+ }
162
+ }
163
+
164
+ // Phase 5: Fallback — not satisfied, escalate to main model for final check
165
+ const finalResult = await callMainModel(goalCondition, ctx, evidenceFromEnv, messages)
166
+ return {
167
+ ...finalResult,
168
+ level: 'final',
169
+ evidence: [...evidenceFromEnv, ...finalResult.evidence],
170
+ }
171
+ }
172
+
173
+ // ============================================================================
174
+ // Context builders
175
+ // ============================================================================
176
+
177
+ /** Extract text from the last 5 assistant messages for EVAL parsing. */
178
+ function extractAssistantTexts(messages: MessageType[]): string {
179
+ return messages
85
180
  .filter(m => m.type === 'assistant' || m.role === 'assistant')
86
181
  .slice(-5)
87
182
  .map(m => {
@@ -96,123 +191,614 @@ export async function evaluateGoal(
96
191
  })
97
192
  .filter(Boolean)
98
193
  .join('\n---\n')
194
+ }
99
195
 
100
- // Phase 1: regex-parse EVAL blocks from recent output (fast, no model call)
101
- const evalBlocks = parseEvalBlocks(recentAssistantTexts)
196
+ /** Build a full evaluation context from recent messages. */
197
+ async function buildEvaluationContext(messages: MessageType[]): Promise<EvaluationContext> {
198
+ const toolResults = extractRecentToolResults(messages)
199
+ const fileDiffs = extractFileDiffs(messages)
200
+ const testOutputs = extractTestOutputs(messages)
201
+ const compileOutputs = extractCompileOutputs(messages)
202
+ const gitStatus = await getGitStatus()
203
+ const errorLogs = getRecentErrors()
102
204
 
103
- // If ALL named metrics pass, the agent itself confirms goal completion.
104
- if (evalBlocks.length > 0 && allMetricsPassing(evalBlocks)) {
105
- return {
106
- satisfied: true,
107
- reason: `all ${evalBlocks.length} EVAL metrics satisfied`,
108
- gap: null,
205
+ const fileList = getTrackedFiles(messages)
206
+
207
+ return {
208
+ toolResults,
209
+ fileDiffs,
210
+ testOutputs,
211
+ compileOutputs,
212
+ gitStatus,
213
+ fileList,
214
+ errorLogs,
215
+ }
216
+ }
217
+
218
+ // ============================================================================
219
+ // Rule Engine — deterministic checks that produce evidence
220
+ // ============================================================================
221
+
222
+ /**
223
+ * Built-in rule registry. Each rule is a pure function:
224
+ * (EvaluationContext, SubGoal) → EvalEvidence[]
225
+ *
226
+ * Rules are composable — the engine runs all registered rules and
227
+ * aggregates their output. New rules can be added by extending this
228
+ * registry (Phase 4: plugin system).
229
+ */
230
+ const BUILT_IN_RULES: Record<string, (ctx: EvaluationContext, goal: import('../types/goal.js').SubGoal) => import('../types/goal.js').EvalEvidence[]> = {
231
+
232
+ // === File existence ===
233
+
234
+ /** Check if files mentioned in the goal exist on disk. Uses fs.accessSync
235
+ * to verify each file path without reading the content. */
236
+ fileExists(ctx: EvaluationContext, _goal: import('../types/goal.js').SubGoal): import('../types/goal.js').EvalEvidence[] {
237
+ const evidence: import('../types/goal.js').EvalEvidence[] = []
238
+ for (const filePath of ctx.fileList) {
239
+ try {
240
+ const { statSync } = require('fs') as typeof import('fs')
241
+ statSync(filePath)
242
+ evidence.push({
243
+ source: 'filesystem',
244
+ finding: `File exists: ${filePath}`,
245
+ relevanceScore: 0.8,
246
+ })
247
+ } catch {
248
+ // File doesn't exist — no evidence (missing file is not proof)
249
+ }
250
+ }
251
+ return evidence
252
+ },
253
+
254
+ // === Test output ===
255
+
256
+ /** Check if any test output indicates passing tests. Parses stdout/stderr
257
+ * for pass/fail patterns and produces evidence accordingly. */
258
+ testsPass(ctx: EvaluationContext, _goal: import('../types/goal.js').SubGoal): import('../types/goal.js').EvalEvidence[] {
259
+ const evidence: import('../types/goal.js').EvalEvidence[] = []
260
+ for (const test of ctx.testOutputs) {
261
+ if (test.passed) {
262
+ evidence.push({
263
+ source: 'test-runner',
264
+ finding: `Tests passed: ${test.command} (exit ${test.exitCode})`,
265
+ relevanceScore: 0.9,
266
+ })
267
+ } else {
268
+ evidence.push({
269
+ source: 'test-runner',
270
+ finding: `Tests failed: ${test.command} (${test.stderr.slice(0, 200)})`,
271
+ relevanceScore: 0.8,
272
+ })
273
+ }
274
+ }
275
+ return evidence
276
+ },
277
+
278
+ // === Compilation ===
279
+
280
+ /** Check if build/compilation succeeded. Looks for successful build outputs
281
+ * in the compileOutputs array. */
282
+ buildPassed(ctx: EvaluationContext, _goal: import('../types/goal.js').SubGoal): import('../types/goal.js').EvalEvidence[] {
283
+ const evidence: import('../types/goal.js').EvalEvidence[] = []
284
+ for (const compile of ctx.compileOutputs) {
285
+ if (compile.passed) {
286
+ evidence.push({
287
+ source: 'compiler',
288
+ finding: `Build passed: ${compile.command}`,
289
+ relevanceScore: 0.9,
290
+ })
291
+ } else {
292
+ evidence.push({
293
+ source: 'compiler',
294
+ finding: `Build failed: ${compile.command} — ${compile.stderr.slice(0, 200)}`,
295
+ relevanceScore: 0.7,
296
+ })
297
+ }
298
+ }
299
+ return evidence
300
+ },
301
+
302
+ // === Schema validation ===
303
+
304
+ /** Check if data schemas are valid. Currently checks TypeScript compilation
305
+ * errors in the error log and compile outputs as a proxy for schema validity.
306
+ * Future: integrate with actual schema validators (JSON Schema, Zod, etc.) */
307
+ schemaValid(ctx: EvaluationContext, _goal: import('../types/goal.js').SubGoal): import('../types/goal.js').EvalEvidence[] {
308
+ const evidence: import('../types/goal.js').EvalEvidence[] = []
309
+ // Check for TypeScript errors as a proxy for type-safety
310
+ for (const error of ctx.errorLogs) {
311
+ if (error.error.includes('error TS')) {
312
+ evidence.push({
313
+ source: 'type-checker',
314
+ finding: `TypeScript error: ${error.error.slice(0, 200)}`,
315
+ relevanceScore: 0.6,
316
+ })
317
+ }
318
+ }
319
+ return evidence
320
+ },
321
+ }
322
+
323
+ /** Run all built-in rules against the current evaluation context and target
324
+ * sub-goal. Returns combined evidence from all rule checks.
325
+ *
326
+ * This is the engine that drives the rule-based evaluation tier.
327
+ * Future: add plugin registration for custom project-specific rules. */
328
+ function runRules(ctx: EvaluationContext, goal: import('../types/goal.js').SubGoal): import('../types/goal.js').EvalEvidence[] {
329
+ const results: import('../types/goal.js').EvalEvidence[] = []
330
+
331
+ // File existence rules
332
+ results.push(...BUILT_IN_RULES.fileExists(ctx, goal))
333
+
334
+ // Test output rules
335
+ results.push(...BUILT_IN_RULES.testsPass(ctx, goal))
336
+
337
+ // Build/compilation rules
338
+ results.push(...BUILT_IN_RULES.buildPassed(ctx, goal))
339
+
340
+ // Schema validation rules
341
+ results.push(...BUILT_IN_RULES.schemaValid(ctx, goal))
342
+
343
+ return results
344
+ }
345
+
346
+ // ============================================================================
347
+ // Evidence gathering
348
+ // ============================================================================
349
+
350
+ /** Generate a human-readable evaluation summary from context + evidence. */
351
+ function gatherEvidence(ctx: EvaluationContext): EvalEvidence[] {
352
+ const evidence: EvalEvidence[] = []
353
+
354
+ // Tool results → evidence
355
+ for (const tr of ctx.toolResults) {
356
+ if (!tr.success && tr.toolName !== 'Read') {
357
+ evidence.push({
358
+ source: tr.toolName,
359
+ finding: `Tool ${tr.toolName} failed: ${tr.toolOutput.slice(0, 200)}`,
360
+ relevanceScore: 0.7,
361
+ })
362
+ } else if (tr.success) {
363
+ evidence.push({
364
+ source: tr.toolName,
365
+ finding: `Tool ${tr.toolName} succeeded`,
366
+ relevanceScore: 0.5,
367
+ })
109
368
  }
110
369
  }
111
370
 
112
- // If no EVAL blocks found at all, provide helpful guidance to the user
113
- if (evalBlocks.length === 0) {
114
- return {
115
- satisfied: false,
116
- reason: 'No EVAL blocks found in assistant output',
117
- gap: 'Please output EVAL blocks in format: "EVAL: metric: value / target → ✓" (without > prefix)',
371
+ // File changes evidence of progress
372
+ for (const diff of ctx.fileDiffs) {
373
+ if (diff.added > 0 || diff.changed > 0) {
374
+ evidence.push({
375
+ source: diff.path,
376
+ finding: `Changed ${diff.path}: +${diff.added} -${diff.removed}`,
377
+ relevanceScore: 0.6,
378
+ })
118
379
  }
119
380
  }
120
381
 
121
- // Phase 2: Fallback to Haiku evaluator with pre-parsed summary
122
- const evalInput = [
123
- evalSummary(evalBlocks),
124
- '(note: EVAL blocks already pre-parsed above — use to guide your evaluation)',
125
- '',
126
- recentAssistantTexts.slice(-4000), // trim long messages to fit context
127
- ].join('\n')
382
+ // Test outputs strong signal
383
+ for (const test of ctx.testOutputs) {
384
+ evidence.push({
385
+ source: 'test-runner',
386
+ finding: test.passed
387
+ ? `Tests passed: ${test.command}`
388
+ : `Tests failed: ${test.command} (exit ${test.exitCode})`,
389
+ relevanceScore: test.passed ? 0.9 : 0.8,
390
+ })
391
+ }
128
392
 
129
- const client = new Anthropic({
130
- baseURL: process.env.ANTHROPIC_BASE_URL ?? undefined,
131
- apiKey: process.env.ANTHROPIC_API_KEY ?? 'dummy',
132
- })
393
+ // Compile outputs strong signal
394
+ for (const compile of ctx.compileOutputs) {
395
+ evidence.push({
396
+ source: 'compiler',
397
+ finding: compile.passed
398
+ ? `Build passed: ${compile.command}`
399
+ : `Build failed: ${compile.command} — ${compile.stderr.slice(0, 200)}`,
400
+ relevanceScore: compile.passed ? 0.9 : 0.8,
401
+ })
402
+ }
133
403
 
134
- const prompt = `Goal condition to evaluate: "${goalCondition}"
404
+ return evidence
405
+ }
135
406
 
136
- The assistant's recent output is below. Based ONLY on it, determine if the goal is satisfied.
407
+ // ============================================================================
408
+ // Haiku evaluation
409
+ // ============================================================================
137
410
 
138
- RESPOND WITH ONLY VALID JSON — no markdown, no explanation:
139
- {"satisfied": true|false, "reason": "<one sentence why>", "gap": "<what's still missing, or null if satisfied>"}
411
+ async function callHaiku(
412
+ goalCondition: string,
413
+ evalInput: string,
414
+ ): Promise<EvalResult> {
415
+ try {
416
+ const client = createClient()
417
+ const response = await client.messages.create({
418
+ model: GOAL_EVALUATOR_MODEL,
419
+ max_tokens: EVAL_MAX_TOKENS,
420
+ system: `You are a goal evaluator. Determine if the goal "${goalCondition}" is satisfied based on the evidence provided. Respond ONLY with valid JSON.`,
421
+ messages: [{ role: 'user', content: evalInput.slice(0, 4000) }],
422
+ })
140
423
 
141
- ${evalInput.slice(0, 5000)}`
424
+ const text = response.content.find((b: any) => b.type === 'text')?.text || ''
425
+ const parsed = parseEvalResponse(text)
426
+ return {
427
+ satisfied: parsed.satisfied ?? false,
428
+ level: 'semantic',
429
+ reason: parsed.reason ?? 'No reason provided',
430
+ gap: parsed.gap ?? null,
431
+ evidence: [],
432
+ }
433
+ } catch (err) {
434
+ return {
435
+ satisfied: false,
436
+ level: 'semantic',
437
+ reason: `Evaluator API error: ${err instanceof Error ? err.message : String(err)}`,
438
+ gap: 'API call failed',
439
+ evidence: [],
440
+ }
441
+ }
442
+ }
443
+
444
+ // ============================================================================
445
+ // Main model evaluation (final tier)
446
+ // ============================================================================
142
447
 
143
- let text = ''
448
+ async function callMainModel(
449
+ goalCondition: string,
450
+ ctx: EvaluationContext,
451
+ evidence: EvalEvidence[],
452
+ messages: MessageType[],
453
+ ): Promise<EvalResult> {
144
454
  try {
455
+ const client = createClient()
456
+ const summary = buildEvaluationSummary(goalCondition, ctx, evidence, messages)
145
457
  const response = await client.messages.create({
146
- model: GOAL_EVALUATOR_MODEL,
147
- max_tokens: 256,
148
- messages: [{ role: 'user', content: prompt }],
458
+ model: getMainModel(),
459
+ max_tokens: EVAL_MAX_TOKENS,
460
+ system: `You are a goal evaluator. Assess whether the goal "${goalCondition}" is achieved based on the following evidence. Respond ONLY with valid JSON ({"satisfied": true/false, "reason": "...", "gap": "..."}). Be thorough — this is the final check before marking the goal complete.`,
461
+ messages: [{ role: 'user', content: summary.slice(0, 4000) }],
149
462
  })
150
- text = response.content.find((b: any) => b.type === 'text')?.text || ''
151
- } catch (e) {
152
- // Short-circuit on API error — parse what we can
463
+
464
+ const text = response.content.find((b: any) => b.type === 'text')?.text || ''
465
+ const parsed = parseEvalResponse(text)
466
+ return {
467
+ satisfied: parsed.satisfied ?? false,
468
+ level: 'final',
469
+ reason: parsed.reason ?? 'No reason provided',
470
+ gap: parsed.gap ?? null,
471
+ evidence,
472
+ }
473
+ } catch (err) {
153
474
  return {
154
475
  satisfied: false,
155
- reason: 'Evaluator API error',
156
- gap: e instanceof Error ? e.message : String(e),
476
+ level: 'final',
477
+ reason: `Final evaluator error: ${err instanceof Error ? err.message : String(err)}`,
478
+ gap: 'API call failed',
479
+ evidence,
157
480
  }
158
481
  }
482
+ }
159
483
 
160
- // Phase 3: Parse evaluator output back to JSON.
161
- // Try strict JSON first, then fuzzy extraction, then interpret heuristics.
162
- const parseError = (detail: string): GoalEvalResult => ({
163
- satisfied: false,
164
- reason: 'Evaluator parse error',
165
- gap: `Failed to parse evaluator output. Detail: ${detail}. First 120 chars of raw response: ${text.slice(0, 120)}`,
166
- })
484
+ // ============================================================================
485
+ // Helpers
486
+ // ============================================================================
487
+
488
+ /** Get the main model name from environment or default. */
489
+ function getMainModel(): string {
490
+ return process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-6-20250514'
491
+ }
167
492
 
168
- const tryJsonParse = (raw: string): { ok: true; value: GoalEvalResult } | { ok: false } => {
169
- try {
170
- let cleaned = raw
171
- .replace(/```(?:json)?\s*/gi, '')
172
- .replace(/```/g, '')
173
- .trim()
174
- const start = cleaned.indexOf('{')
175
- const end = cleaned.lastIndexOf('}')
176
- if (start === -1 || end === -1 || end <= start) return { ok: false }
493
+ /** Parse evaluation response JSON. Handles markdown and prefix text. */
494
+ function parseEvalResponse(text: string): { satisfied?: boolean; reason?: string; gap?: string | null } {
495
+ try {
496
+ let cleaned = text
497
+ .replace(/```(?:json)?\s*/gi, '')
498
+ .replace(/```/g, '')
499
+ .trim()
500
+ const start = cleaned.indexOf('{')
501
+ const end = cleaned.lastIndexOf('}')
502
+ if (start !== -1 && end !== -1 && end > start) {
177
503
  cleaned = cleaned.slice(start, end + 1)
178
504
  const parsed = JSON.parse(cleaned)
179
- if (typeof parsed.satisfied === 'boolean') {
180
- return {
181
- ok: true,
182
- value: {
183
- satisfied: parsed.satisfied,
184
- reason: parsed.reason || '',
185
- gap: parsed.gap || null,
186
- },
505
+ return {
506
+ satisfied: !!parsed.satisfied,
507
+ reason: parsed.reason || '',
508
+ gap: parsed.gap || null,
509
+ }
510
+ }
511
+ return {}
512
+ } catch {
513
+ return {}
514
+ }
515
+ }
516
+
517
+ /** Build comprehensive evaluation input for Haiku from context + evidence. */
518
+ function buildEvalInput(
519
+ goalCondition: string,
520
+ evalBlocks: EvalBlock[],
521
+ ctx: EvaluationContext,
522
+ evidence: EvalEvidence[],
523
+ messages: MessageType[],
524
+ ): string {
525
+ const parts: string[] = []
526
+
527
+ // EVAL blocks summary
528
+ if (evalBlocks.length > 0) {
529
+ parts.push(evalSummary(evalBlocks))
530
+ }
531
+
532
+ // Evidence summary
533
+ if (evidence.length > 0) {
534
+ parts.push(`Environmental evidence (${evidence.length} findings):`)
535
+ parts.push(...evidence.map(e => `- ${e.source}: ${e.finding} [${e.relevanceScore.toFixed(1)}]`))
536
+ }
537
+
538
+ // Recent agent output
539
+ const recentTexts = extractAssistantTexts(messages).slice(-2000)
540
+ if (recentTexts) {
541
+ parts.push(`Recent agent output:\n${recentTexts}`)
542
+ }
543
+
544
+ return parts.join('\n')
545
+ }
546
+
547
+ // ============================================================================
548
+ // Environment extraction helpers (from messages)
549
+ // ============================================================================
550
+
551
+ function extractRecentToolResults(messages: MessageType[]): ToolResult[] {
552
+ const results: ToolResult[] = []
553
+ for (const msg of messages) {
554
+ if (msg.type === 'assistant' || msg.role === 'assistant') {
555
+ const content = msg.message?.content
556
+ if (Array.isArray(content)) {
557
+ for (const block of content) {
558
+ if (block.type === 'tool_result') {
559
+ results.push({
560
+ toolName: block.tool_name ?? 'unknown',
561
+ toolInput: (block as any).tool_input ?? {},
562
+ toolOutput: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
563
+ success: !(block as any).is_error,
564
+ timestamp: Date.now(),
565
+ })
566
+ }
187
567
  }
188
568
  }
189
- return { ok: false }
190
- } catch {
191
- return { ok: false }
192
569
  }
193
570
  }
571
+ return results
572
+ }
194
573
 
195
- // Attempt 1 strict JSON parse of the raw text
196
- const result = tryJsonParse(text)
197
- if (result.ok) return result.value
574
+ function extractFileDiffs(messages: MessageType[]): FileDiff[] {
575
+ const diffs: FileDiff[] = []
576
+ for (const msg of messages) {
577
+ if (msg.type === 'assistant' || msg.role === 'assistant') {
578
+ const content = msg.message?.content
579
+ if (Array.isArray(content)) {
580
+ for (const block of content) {
581
+ if (block.type === 'tool_result' && block.tool_name === 'Bash') {
582
+ const text = typeof block.content === 'string' ? block.content : ''
583
+ if (text.includes('diff --git')) {
584
+ diffs.push(parseDiffLine(text))
585
+ }
586
+ }
587
+ if (block.type === 'text') {
588
+ const text = block.text
589
+ if (text.includes('added') && text.includes('removed')) {
590
+ diffs.push(parseEditStats(text))
591
+ }
592
+ }
593
+ }
594
+ }
595
+ }
596
+ }
597
+ return diffs
598
+ }
198
599
 
199
- // Attempt 2 heuristic extraction from text response
200
- const lower = text.toLowerCase()
201
- const looksSatisfied =
202
- lower.includes('"satisfied": true') ||
203
- (lower.includes('satisfied') && lower.includes('true')) ||
204
- /goal\s+is\s+(?:met|satisfied|achieved)/.test(lower) ||
205
- /condition\s+is\s+(?:met|satisfied|fulfilled)/.test(lower)
600
+ /** Parse a git diff --stat line into FileDiff counts. */
601
+ function parseDiffLine(text: string): FileDiff {
602
+ const match = text.match(/^diff --git a\/(.*) b\/(.*)$/m)
603
+ let added = 0; let removed = 0; let changed = 0
206
604
 
207
- const extractString = (field: string): string => {
208
- const regex = new RegExp(`"${field}"\\s*:\\s*"([^"]*)"`, 'i')
209
- const match = text.match(regex)
210
- return match ? match[1] : 'unknown'
605
+ for (const line of text.split('\n')) {
606
+ if (line.startsWith('+++') || line.startsWith('---')) continue
607
+ if (line.startsWith('+')) added++
608
+ else if (line.startsWith('-')) removed++
609
+ else changed++
211
610
  }
212
611
 
213
612
  return {
214
- satisfied: looksSatisfied,
215
- reason: extractString('reason') || (looksSatisfied ? 'condition matched' : 'condition not met'),
216
- gap: extractString('gap') || null,
613
+ path: match?.[1] ?? 'unknown',
614
+ added,
615
+ removed,
616
+ changed,
617
+ }
618
+ }
619
+
620
+ function extractTestOutputs(messages: MessageType[]): TestOutput[] {
621
+ const outputs: TestOutput[] = []
622
+ for (const msg of messages) {
623
+ if (msg.type === 'user' || msg.role === 'user') {
624
+ // Check user messages for injected test output (from test runner hooks)
625
+ const text = typeof msg.message?.content === 'string'
626
+ ? msg.message.content
627
+ : Array.isArray(msg.message?.content)
628
+ ? msg.message.content.map((b: any) => b.text ?? '').join('\n')
629
+ : ''
630
+ if (text.includes('PASS') || text.includes('FAIL')) {
631
+ outputs.push({
632
+ command: extractCommand(text),
633
+ exitCode: text.includes('FAIL') ? 1 : 0,
634
+ stdout: text,
635
+ stderr: '',
636
+ passed: !text.includes('FAIL'),
637
+ })
638
+ }
639
+ }
217
640
  }
218
- }
641
+ return outputs
642
+ }
643
+
644
+ function extractCompileOutputs(messages: MessageType[]): CompileOutput[] {
645
+ const outputs: CompileOutput[] = []
646
+ for (const msg of messages) {
647
+ if (msg.type === 'user' || msg.role === 'user') {
648
+ const text = typeof msg.message?.content === 'string'
649
+ ? msg.message.content
650
+ : ''
651
+ if (text.includes('error TS') || text.includes('Error: ') || text.includes('BUILD FAILED')) {
652
+ outputs.push({
653
+ command: extractCommand(text),
654
+ exitCode: 1,
655
+ stdout: '',
656
+ stderr: text,
657
+ passed: false,
658
+ })
659
+ }
660
+ }
661
+ }
662
+ return outputs
663
+ }
664
+
665
+ /** Extract the command that produced this output. */
666
+ function extractCommand(text: string): string {
667
+ const match = text.match(/^(?:> |\$ )?(.+?)(?:\n|$)/)
668
+ return match?.[1] ?? 'unknown'
669
+ }
670
+
671
+ /** Parse edit stats from agent text (e.g., "Modified 3 files: +45 -12"). */
672
+ function parseEditStats(text: string): FileDiff {
673
+ const m = text.match(/\+(\d+)\s*-\s*(\d+)/)
674
+ return {
675
+ path: 'unknown',
676
+ added: m ? parseInt(m[1]) : 0,
677
+ removed: m ? parseInt(m[2]) : 0,
678
+ changed: 0,
679
+ }
680
+ }
681
+
682
+ // ============================================================================
683
+ // Git status
684
+ // ============================================================================
685
+
686
+ async function getGitStatus(): Promise<GitStatus> {
687
+ try {
688
+ // Use child_process directly to avoid circular imports
689
+ const { execSync } = await import('child_process')
690
+ const output = execSync('git status --porcelain', { encoding: 'utf8', cwd: process.cwd() })
691
+ const lines = output.trim().split('\n').filter(Boolean)
692
+ return {
693
+ staged: lines.filter(l => l[0] !== ' ' && l[1] !== ' ').map(l => l.slice(3)),
694
+ unstaged: lines.filter(l => l[0] === ' ' || l[1] === ' ').map(l => l.slice(3)),
695
+ untracked: lines.filter(l => l.startsWith('??')).map(l => l.slice(3)),
696
+ branch: execSync('git branch --show-current', { encoding: 'utf8' }).trim(),
697
+ clean: lines.length === 0,
698
+ }
699
+ } catch {
700
+ return { staged: [], unstaged: [], untracked: [], branch: '', clean: true }
701
+ }
702
+ }
703
+
704
+ // ============================================================================
705
+ // System state accessors
706
+ // ============================================================================
707
+
708
+ /** Get tracked files from goal store artifacts and git status. */
709
+ function getTrackedFiles(messages: MessageType[]): string[] {
710
+ // Combine artifacts from goal store with any files mentioned in recent tool calls
711
+ const files = new Set<string>()
712
+ for (const msg of messages) {
713
+ if (msg.type === 'user' || msg.role === 'user') continue
714
+ const content = msg.message?.content
715
+ if (Array.isArray(content)) {
716
+ for (const block of content) {
717
+ if (block.type === 'tool_result') {
718
+ // Extract file paths from tool results (e.g., "Wrote to file.txt")
719
+ const text = typeof block.content === 'string' ? block.content : ''
720
+ const paths = extractFilePaths(text)
721
+ for (const p of paths) files.add(p)
722
+ }
723
+ }
724
+ }
725
+ }
726
+ return Array.from(files)
727
+ }
728
+
729
+ /** Extract file paths from tool output text. */
730
+ function extractFilePaths(text: string): string[] {
731
+ const paths: string[] = []
732
+ // Match patterns like "Wrote contents to /path/to/file.txt"
733
+ const wroteMatch = /(?:Wrote|Saved|Created)\s+(?:contents\s+)?(?:to\s+)?([^\s]+)/gi
734
+ let match: RegExpExecArray | null
735
+ while ((match = wroteMatch.exec(text)) !== null) {
736
+ paths.push(match[1])
737
+ }
738
+ return paths
739
+ }
740
+
741
+ /** Get recent errors from the in-memory error log ring buffer. */
742
+ function getRecentErrors(): ErrorLog[] {
743
+ try {
744
+ // Access the global STATE via bootstrap
745
+ const { getState } = require('../bootstrap/state.js')
746
+ // The inMemoryErrorLog field exists on the STATE object
747
+ const errors = (getState() as any).inMemoryErrorLog ?? []
748
+ return errors.slice(-20).map((e: any) => ({ error: e.error, timestamp: e.timestamp }))
749
+ } catch {
750
+ return []
751
+ }
752
+ }
753
+
754
+ // ============================================================================
755
+ // Progress checking
756
+ // ============================================================================
757
+
758
+ /** Check if there's concrete progress in the environment that indicates
759
+ * the goal is likely achieved. */
760
+ function checkConcreteProgress(
761
+ evidence: EvalEvidence[],
762
+ goalCondition: string,
763
+ ): { satisfied: boolean; reason: string } {
764
+ // If no evidence at all, can't claim progress
765
+ if (evidence.length === 0) {
766
+ return { satisfied: false, reason: 'No environmental evidence found' }
767
+ }
768
+
769
+ // Check for strong positive signals
770
+ const hasSuccessfulTool = evidence.some(e => e.relevanceScore >= 0.8)
771
+ const hasCodeChange = evidence.some(e => e.source === 'test-runner' || e.source === 'compiler')
772
+ const hasFileOutput = evidence.some(e => e.source !== 'agent-output')
773
+
774
+ if (hasSuccessfulTool || hasCodeChange || hasFileOutput) {
775
+ return { satisfied: true, reason: `Concrete progress detected (${evidence.length} signals)` }
776
+ }
777
+
778
+ return { satisfied: false, reason: 'Insufficient concrete progress evidence' }
779
+ }
780
+
781
+ // ============================================================================
782
+ // State accessors (delegate to goal store)
783
+ // ============================================================================
784
+
785
+ /** Get the current goal state for evaluation context building. */
786
+ function getGoalStateSnapshot(): GoalState {
787
+ try {
788
+ return getGoalState()
789
+ } catch {
790
+ return {
791
+ activeGoalId: null,
792
+ userGoal: null,
793
+ operationalGoal: null,
794
+ dag: { nodes: new Map(), edges: [], readyQueue: [] },
795
+ immediateGoal: null,
796
+ progress: 0,
797
+ artifacts: [],
798
+ metrics: { totalSubGoals: 0, completedSubGoals: 0, iterationCount: 0, maxIterations: 20, evalHistory: { lastGap: null, consecutiveSameGapCount: 0 } },
799
+ }
800
+ }
801
+ }
802
+
803
+ // Keep backwards compatibility with existing exports used by useGoalEvaluator.ts
804
+ export { parseEvalBlocks as parseEvalBlocks_legacy, allMetricsPassing, evalSummary }