dsh-llm-verifier 0.3.0 → 0.4.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 (97) hide show
  1. package/README.md +133 -9
  2. package/lib/auto-B5V49e-A.js +1517 -0
  3. package/lib/auto-B5V49e-A.js.map +1 -0
  4. package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
  5. package/lib/caller-DR_C7ueP.js.map +1 -0
  6. package/lib/caller.js +2 -2
  7. package/lib/client.js +804 -29
  8. package/lib/client.js.map +1 -1
  9. package/lib/core.js +226 -3
  10. package/lib/core.js.map +1 -1
  11. package/lib/index.js +1014 -1337
  12. package/lib/index.js.map +1 -1
  13. package/lib/replay.js +157 -0
  14. package/lib/replay.js.map +1 -0
  15. package/lib/types/auto.js +1 -1
  16. package/lib/types/auto.js.map +1 -1
  17. package/lib/types/caller.d.ts +65 -0
  18. package/lib/types/caller.d.ts.map +1 -1
  19. package/lib/types/caller.js +113 -5
  20. package/lib/types/caller.js.map +1 -1
  21. package/lib/types/client-i18n.d.ts +163 -6
  22. package/lib/types/client-i18n.d.ts.map +1 -1
  23. package/lib/types/client-i18n.js +137 -26
  24. package/lib/types/client-i18n.js.map +1 -1
  25. package/lib/types/client.d.ts +30 -0
  26. package/lib/types/client.d.ts.map +1 -1
  27. package/lib/types/client.js +94 -9
  28. package/lib/types/client.js.map +1 -1
  29. package/lib/types/config.d.ts +33 -0
  30. package/lib/types/config.d.ts.map +1 -1
  31. package/lib/types/config.js +17 -2
  32. package/lib/types/config.js.map +1 -1
  33. package/lib/types/core.d.ts +84 -0
  34. package/lib/types/core.d.ts.map +1 -1
  35. package/lib/types/core.js +222 -2
  36. package/lib/types/core.js.map +1 -1
  37. package/lib/types/criteria.d.ts +33 -0
  38. package/lib/types/criteria.d.ts.map +1 -0
  39. package/lib/types/criteria.js +67 -0
  40. package/lib/types/criteria.js.map +1 -0
  41. package/lib/types/decisions.d.ts +84 -0
  42. package/lib/types/decisions.d.ts.map +1 -0
  43. package/lib/types/decisions.js +209 -0
  44. package/lib/types/decisions.js.map +1 -0
  45. package/lib/types/engine.d.ts +54 -1
  46. package/lib/types/engine.d.ts.map +1 -1
  47. package/lib/types/engine.js +175 -12
  48. package/lib/types/engine.js.map +1 -1
  49. package/lib/types/index.d.ts +1 -0
  50. package/lib/types/index.d.ts.map +1 -1
  51. package/lib/types/index.js +392 -38
  52. package/lib/types/index.js.map +1 -1
  53. package/lib/types/replay.d.ts +88 -0
  54. package/lib/types/replay.d.ts.map +1 -0
  55. package/lib/types/replay.js +136 -0
  56. package/lib/types/replay.js.map +1 -0
  57. package/lib/types/router.d.ts +29 -5
  58. package/lib/types/router.d.ts.map +1 -1
  59. package/lib/types/router.js +283 -46
  60. package/lib/types/router.js.map +1 -1
  61. package/lib/types/statistics.d.ts +10 -1
  62. package/lib/types/statistics.d.ts.map +1 -1
  63. package/lib/types/statistics.js +35 -7
  64. package/lib/types/statistics.js.map +1 -1
  65. package/lib/types/top-logprobs.d.ts +36 -0
  66. package/lib/types/top-logprobs.d.ts.map +1 -1
  67. package/lib/types/top-logprobs.js +41 -14
  68. package/lib/types/top-logprobs.js.map +1 -1
  69. package/package.json +6 -1
  70. package/scripts/eval-replay.mjs +118 -0
  71. package/src/auto.ts +1 -1
  72. package/src/caller.test.ts +62 -1
  73. package/src/caller.ts +124 -4
  74. package/src/client-i18n.ts +137 -25
  75. package/src/client.test.ts +21 -0
  76. package/src/client.tsx +145 -7
  77. package/src/config.test.ts +32 -0
  78. package/src/config.ts +49 -2
  79. package/src/core.test.ts +95 -0
  80. package/src/core.ts +217 -2
  81. package/src/criteria.test.ts +60 -0
  82. package/src/criteria.ts +76 -0
  83. package/src/decisions.test.ts +150 -0
  84. package/src/decisions.ts +244 -0
  85. package/src/engine.test.ts +131 -0
  86. package/src/engine.ts +167 -10
  87. package/src/index.test.ts +278 -8
  88. package/src/index.ts +373 -37
  89. package/src/replay.test.ts +82 -0
  90. package/src/replay.ts +155 -0
  91. package/src/router.test.ts +240 -1
  92. package/src/router.ts +298 -48
  93. package/src/statistics.test.ts +49 -1
  94. package/src/statistics.ts +44 -7
  95. package/src/top-logprobs.test.ts +28 -0
  96. package/src/top-logprobs.ts +56 -8
  97. package/lib/caller-DJd_FlrQ.js.map +0 -1
package/src/router.ts CHANGED
@@ -44,8 +44,20 @@ export interface RouterPolicy {
44
44
  mode: AutoVerifyMode
45
45
  minConfidence: number
46
46
  maxCandidates: number
47
- maxPerTask: number
48
- maxPerSession: number
47
+ /**
48
+ * Automatic route attempts (semantic classification, plan pre-review, team-task gate and
49
+ * compare/select/track) allowed within one task and one session.
50
+ *
51
+ * Routing and the final acceptance are metered by SEPARATE counters on purpose. They used
52
+ * to share one, which let routing spend the final gate's share: the gate is mandatory once
53
+ * armed (finalRequiredFromSeq), so a busy task could exhaust the shared counter on routes
54
+ * and then close the turn with the gate never having run.
55
+ */
56
+ maxRoutePerTask: number
57
+ maxRoutePerSession: number
58
+ /** Attempts reserved for the final acceptance; routing can never spend these. */
59
+ maxFinalPerTask: number
60
+ maxFinalPerSession: number
49
61
  maxModelCallsPerTask: number
50
62
  maxModelCallsPerSession: number
51
63
  maxInputChars: number
@@ -55,14 +67,22 @@ export interface RouterPolicy {
55
67
  interface Reservation { id: string; phase: RoutePhase; fingerprint: string; taskStartSeq: number }
56
68
  interface RouterState {
57
69
  taskStartSeq: number
58
- taskAttempts: number
59
- sessionAttempts: number
70
+ routeAttempts: number
71
+ finalAttempts: number
72
+ sessionRouteAttempts: number
73
+ sessionFinalAttempts: number
60
74
  taskModelCalls: number
61
75
  sessionModelCalls: number
62
76
  completed: Set<string>
63
77
  failed: Set<string>
64
78
  inFlight?: Reservation
65
79
  finalRequiredFromSeq?: number
80
+ /**
81
+ * Armed when a track route already cleared the completion threshold. The next stop
82
+ * boundary then skips automatic routing and runs the mandatory final gate instead of
83
+ * paying for another progress route whose steered text would be identical.
84
+ */
85
+ finalPreferred: boolean
66
86
  strictBlocked: boolean
67
87
  }
68
88
 
@@ -163,11 +183,12 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
163
183
  // Track the rolling view of team tasks across the session
164
184
  const currentTeamTasks = new Map<string, TeamTaskItem>()
165
185
  let narration: { seq: number; text: string } | undefined
166
- // Tool names each wrapper call (a `run_code` program) dispatched, keyed by the
167
- // wrapper's callId. Session V3 carries rootCallId on the dispatch event, which is the
168
- // only reliable parent link: the wrapper's own result text is a concatenation of its
169
- // sub-results, so the tool name of the wrapper says nothing about what it did.
170
- const dispatchedTools = new Map<string, Set<string>>()
186
+ // What each wrapper call (a `run_code` program) dispatched, keyed by the wrapper's
187
+ // callId: how many sub-calls it made and whether any of them produced evidence.
188
+ // Session V3 carries rootCallId on the dispatch event, which is the only reliable
189
+ // parent link: the wrapper's own result text is a concatenation of its sub-results,
190
+ // so the tool name of the wrapper says nothing about what it did.
191
+ const dispatchedWork = new Map<string, { count: number; evidence: boolean }>()
171
192
 
172
193
  for (const rawEvent of relevant) {
173
194
  const event = rawEvent as unknown as { type: string; seq: number; data: any }
@@ -192,9 +213,10 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
192
213
  const data = event.data as { rootCallId?: string; subCallId?: string; name: string; isError?: boolean; content?: readonly ContentBlock[] }
193
214
  const rootCallId = typeof data.rootCallId === 'string' && data.rootCallId ? data.rootCallId : undefined
194
215
  if (rootCallId) {
195
- const names = dispatchedTools.get(rootCallId) ?? new Set<string>()
196
- names.add(data.name)
197
- dispatchedTools.set(rootCallId, names)
216
+ const entry = dispatchedWork.get(rootCallId) ?? { count: 0, evidence: false }
217
+ entry.count += 1
218
+ if (isEvidenceOutput(data.name, Array.isArray(data.content) ? blockText(data.content) : '')) entry.evidence = true
219
+ dispatchedWork.set(rootCallId, entry)
198
220
  }
199
221
  const isOk = data.isError !== true && (!Array.isArray(data.content) || data.content.every(b => (b as { isError?: boolean }).isError !== true))
200
222
  if (isOk) {
@@ -208,11 +230,12 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
208
230
  for (const [callId, call] of calls) {
209
231
  const result = results.get(callId)
210
232
  if (!result) continue
211
- // A wrapper that dispatched nothing but bookkeeping carries only their payloads
212
- // (typically the todo list echoed straight back), so it is bookkeeping too. Its
213
- // nested dispatches stay in the index under their own names, so real work done by
214
- // the same program is still available as evidence.
215
- if (onlyBookkeepingDispatches(dispatchedTools.get(callId))) continue
233
+ // A wrapper whose every dispatch was bookkeeping/coordination carries only their
234
+ // payloads (the todo list, or a "started subagent" acknowledgement, echoed straight
235
+ // back), so it is coordination too. Its nested dispatches stay in the index under
236
+ // their own names, so real work done by the same program is still available.
237
+ const dispatched = dispatchedWork.get(callId)
238
+ if (dispatched !== undefined && dispatched.count > 0 && !dispatched.evidence) continue
216
239
  paired.set(callId, { name: call.data.name, callSeq: call.seq, resultSeq: result.seq, text: blockText(result.data.message.content) })
217
240
  }
218
241
  return { problemSeq: taskStartSeq, calls: paired, todos, teamTasks, narration }
@@ -268,16 +291,188 @@ function canonicalTodoSnapshots(index: EvidenceIndex): Array<{ seq: number; todo
268
291
  * A checkpoint already renders the todo/team snapshot these tools wrote, so their
269
292
  * own result repeats it while displacing the real work output that came just before
270
293
  * them.
294
+ *
295
+ * `present` belongs here for the same reason: declaring deliverables produces no
296
+ * independent output, and it is always the LAST call of a turn. A live session ran
297
+ * typecheck + the full suite, committed, then presented — and every stop boundary
298
+ * showed the judge "Latest observed tool output at routing time (run_code):
299
+ * presented: 8" instead of the verification run one call earlier. Following its own
300
+ * rule ("a state without real verification should not exceed K"), the judge capped
301
+ * the newest checkpoint at exactly K = 52.6% against a 0.8 threshold on four
302
+ * consecutive routes, so each one steered "continue the unfinished work" for work
303
+ * that was finished and verified. The `deliverables/presented` event still records
304
+ * what was presented; the tool result adds nothing the judge can grade.
271
305
  */
272
306
  const BOOKKEEPING_TOOLS = new Set([
273
- 'todo_write', 'create_goal', 'get_goal', 'update_goal', 'interrupt_agent', 'list_agents', 'exit_plan_mode', 'skill',
307
+ 'todo_write', 'create_goal', 'get_goal', 'update_goal', 'interrupt_agent', 'list_agents', 'exit_plan_mode', 'skill', 'present',
308
+ 'job_list', 'job_kill', 'list_subagent_models', 'send_message',
274
309
  ])
275
310
 
276
- /** Whether a wrapper's dispatches were all bookkeeping; undefined/empty means it dispatched nothing. */
277
- function onlyBookkeepingDispatches(names: ReadonlySet<string> | undefined): boolean {
278
- if (names === undefined || names.size === 0) return false
279
- for (const name of names) if (!BOOKKEEPING_TOOLS.has(name)) return false
280
- return true
311
+ /**
312
+ * The plugin's own verdict tools.
313
+ *
314
+ * They must stay in the evidence index — {@link successfulExplicitKinds} reads it so an
315
+ * explicit route is not re-run automatically — but they are never rendered as observed
316
+ * work output: a judge grading progress from an earlier verdict would be grading itself,
317
+ * and a stale "passed" would grade as if the work behind it still stood.
318
+ */
319
+ const VERIFIER_EVIDENCE_TOOLS = new Set(['verifier_compare', 'verifier_select', 'verifier_track', 'verifier_best_of_n', 'verifier_current_session'])
320
+
321
+ /** Tools that return either a child's report (evidence) or a bare start acknowledgement (not evidence). */
322
+ const SUBAGENT_TOOLS = new Set(['subagent', 'subagent_fork'])
323
+
324
+ /**
325
+ * Start acknowledgement of a background child.
326
+ *
327
+ * `subagent`/`subagent_fork` cannot be excluded by name: a foreground call returns the
328
+ * child's report, a background one returns exactly `started subagent <childId>` (or
329
+ * `started background subagent job <id>`), which hides the output the child was asked to
330
+ * produce while looking like the newest "observed" result of the task.
331
+ */
332
+ const CHILD_START_ACKNOWLEDGEMENT = /^started (?:background )?subagent\b/u
333
+
334
+ /**
335
+ * Whether one settled tool result is evidence of work rather than coordination output.
336
+ *
337
+ * Single definition for every site that renders or offers evidence, so the checkpoint
338
+ * picker and the semantic router cannot disagree about what counts — the same
339
+ * disagreement produced the K-cap loop described on {@link BOOKKEEPING_TOOLS}.
340
+ * @param name - tool name that produced the result.
341
+ * @param text - rendered result text.
342
+ * @returns True when the result may be shown to a judge as observed output.
343
+ */
344
+ function isEvidenceOutput(name: string, text: string): boolean {
345
+ if (BOOKKEEPING_TOOLS.has(name) || VERIFIER_EVIDENCE_TOOLS.has(name)) return false
346
+ const body = text.trim()
347
+ if (!body) return false
348
+ return !(SUBAGENT_TOOLS.has(name) && CHILD_START_ACKNOWLEDGEMENT.test(body))
349
+ }
350
+
351
+ /** Signatures of an output that reports verification results. */
352
+ const VERIFICATION_SIGNATURES: readonly RegExp[] = [
353
+ /\bTest Files\s+\d+/u,
354
+ /\bTests?\s*:?\s*\d+\s+(?:passed|failed|skipped|todo)/iu,
355
+ /\bTest Suites?:\s*\d+/u,
356
+ /\b\d+\s+passed\b/u,
357
+ /\b(?:all\s+)?tests?\s+passed\b/iu,
358
+ /^\s*(?:ok|FAIL|PASS)\s+\S+/mu,
359
+ /\b(?:TEST|TYPECHECK|BUILD|LINT|CHECK|GATE)_EXIT\s*[:=]\s*0\b/u,
360
+ ]
361
+
362
+ /**
363
+ * Whether an output looks like a test/typecheck/build run reporting its result.
364
+ *
365
+ * Only decides whether one extra evidence block is worth rendering. A miss degrades to
366
+ * the single-output rendering the checkpoints always had, and a false positive shows the
367
+ * judge one more observed result — neither can invent evidence.
368
+ * @param text - rendered tool result.
369
+ * @returns True when the text carries a runner-shaped summary.
370
+ */
371
+ function looksLikeVerificationRun(text: string): boolean {
372
+ return VERIFICATION_SIGNATURES.some(pattern => pattern.test(text))
373
+ }
374
+
375
+ /**
376
+ * How much settled tool work followed one call, as a short histogram.
377
+ *
378
+ * Lets the judge decide whether a verification run still covers the current state
379
+ * ("nothing but issue replies since" versus "12 writes since") instead of guessing.
380
+ * Counts come from the evidence index, so a wrapper and its dispatches are each counted
381
+ * as the results they produced.
382
+ * @param index - evidence index of the current task.
383
+ * @param call - the call to measure from.
384
+ * @param maxChars - hard cap for the summary.
385
+ * @returns The summary text, or '' when nothing followed.
386
+ */
387
+ function trailingSummary(index: EvidenceIndex, call: EvidenceCall, maxChars: number): string {
388
+ const counts = new Map<string, number>()
389
+ let total = 0
390
+ for (const other of index.calls.values()) {
391
+ if (other.resultSeq <= call.resultSeq) continue
392
+ total += 1
393
+ counts.set(other.name, (counts.get(other.name) ?? 0) + 1)
394
+ }
395
+ if (total === 0) return ''
396
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
397
+ const summary = total + ' tool result(s) since, ' + ranked.slice(0, 4).map(([name, count]) => name + ' ×' + count).join(', ') + (ranked.length > 4 ? ', …' : '')
398
+ return summary.length <= maxChars ? summary : summary.slice(0, Math.max(1, maxChars - 1)) + '…'
399
+ }
400
+
401
+ /**
402
+ * One extra block for the newest verification run in the task.
403
+ *
404
+ * The judge's own rule caps a state whose evidence carries no verification (see
405
+ * {@link BOOKKEEPING_TOOLS}), but only ONE output fits per checkpoint — and the last
406
+ * call of a task is rarely the test run. A live session ran the full suite and then
407
+ * spent 88 tool calls closing issues and printing a status summary: the newest
408
+ * checkpoint showed only that summary, the judge answered exactly K = 52.6% against a
409
+ * 0.8 threshold, and the route steered "continue the unfinished work" for work that was
410
+ * finished and verified. Putting the newest verification run back in front of the judge
411
+ * restores evidence the session really produced; it does not loosen the threshold.
412
+ * @param index - evidence index of the current task.
413
+ * @param newest - the call already rendered as the newest output, if any.
414
+ * @param budget - maximum characters this block may occupy.
415
+ * @returns The labelled block and the run it came from, or an empty block.
416
+ */
417
+ function verificationEvidence(index: EvidenceIndex, newest: EvidenceCall | undefined, budget: number): { text: string; call: EvidenceCall | undefined } {
418
+ if (budget < 128) return { text: '', call: undefined }
419
+ // A freshly observed run already sits in the newest block; showing an older one as
420
+ // well would only add noise (and could read as two independent verifications).
421
+ if (newest !== undefined && looksLikeVerificationRun(newest.text)) return { text: '', call: undefined }
422
+ let run: EvidenceCall | undefined
423
+ for (const pair of index.calls.values()) {
424
+ if (!isEvidenceOutput(pair.name, pair.text) || !looksLikeVerificationRun(pair.text)) continue
425
+ if (run === undefined || pair.resultSeq > run.resultSeq) run = pair
426
+ }
427
+ if (run === undefined) return { text: '', call: undefined }
428
+ const trailing = trailingSummary(index, run, 200)
429
+ const prefix = '\n\nLatest observed verification run (' + run.name + (trailing === '' ? '' : ' — ' + trailing) + '):\n'
430
+ if (prefix.length >= budget) return { text: '', call: undefined }
431
+ return { text: prefix + sanitizeVerifierText(run.text, budget - prefix.length), call: run }
432
+ }
433
+
434
+ /** Upper bound on the one-line-per-call digest attached to the newest checkpoint. */
435
+ const MAX_DIGEST_LINES = 8
436
+
437
+ /**
438
+ * One line per recent tool result, newest last.
439
+ *
440
+ * The detailed blocks above show the newest output and the newest verification run; a
441
+ * judge deciding whether that verification still covers the current state also needs to
442
+ * know what the calls in between actually did. The trailing histogram on the
443
+ * verification block names the tools, this names the work ("gh api … state=closed",
444
+ * "Updated todo list: 0 pending"), and it deliberately keeps coordination results — they
445
+ * are exactly what a tail of the task looks like.
446
+ *
447
+ * Rendered only for the checkpoint being judged, bounded by its own slice of the same
448
+ * per-item budget, and always keeping the newest lines when the budget is tight.
449
+ * @param index - evidence index of the current task.
450
+ * @param budget - maximum characters this block may occupy.
451
+ * @param shown - calls already rendered in full above, marked instead of dropped.
452
+ * @returns The labelled block, or '' when there is nothing recent to add.
453
+ */
454
+ function recentEvidenceDigest(index: EvidenceIndex, budget: number, shown: readonly (EvidenceCall | undefined)[]): string {
455
+ if (budget < 128) return ''
456
+ // The plugin's own verdicts stay out even here: a one-line "winner A, score 1" in the
457
+ // judge's context is the self-grading this module refuses everywhere else.
458
+ const recent = [...index.calls.values()]
459
+ .filter(call => !VERIFIER_EVIDENCE_TOOLS.has(call.name))
460
+ .sort((a, b) => a.resultSeq - b.resultSeq)
461
+ .slice(-MAX_DIGEST_LINES)
462
+ if (recent.length === 0) return ''
463
+ const rendered = recent.map(call => {
464
+ const first = call.text.split('\n').map(line => line.trim()).find(line => line.length > 0) ?? ''
465
+ const mark = shown.includes(call) ? ' [shown above]' : ''
466
+ return ' [' + call.resultSeq + '] ' + (call.name + ': ' + first).slice(0, 110) + mark
467
+ })
468
+ const prefix = '\n\nRecent tool results (newest last):\n'
469
+ if (prefix.length >= budget) return ''
470
+ const room = budget - prefix.length
471
+ let kept = rendered
472
+ while (kept.length > 1 && kept.join('\n').length > room) kept = kept.slice(1)
473
+ const omitted = kept.length < rendered.length ? ' …older omitted\n' : ''
474
+ const body = omitted + kept.join('\n')
475
+ return prefix + (body.length > room ? body.slice(body.length - room) : body)
281
476
  }
282
477
 
283
478
  /**
@@ -292,29 +487,31 @@ function onlyBookkeepingDispatches(names: ReadonlySet<string> | undefined): bool
292
487
  * snapshot they wrote, so their own result repeats it while displacing the real
293
488
  * output produced just before them (a live session attached `create_goal`,
294
489
  * `update_goal` and `interrupt_agent` output to checkpoints that had already run
295
- * the task's test suite).
490
+ * the task's test suite). Coordination results are skipped by the same rule — see
491
+ * {@link isEvidenceOutput} — because they are also written after the work.
296
492
  * @param index - evidence index of the current task.
297
493
  * @param seq - checkpoint sequence number, or `Infinity` for the current state.
298
494
  * @param budget - maximum characters the evidence may occupy.
299
495
  * @param current - render the newest output in the task instead of the newest one before `seq`.
300
- * @returns Evidence block, or '' when the task produced none yet.
496
+ * @returns The rendered block and the call it came from, or an empty block.
301
497
  */
302
- function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number, current = false): string {
303
- if (budget < 64) return ''
498
+ function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number, current = false): { text: string; call: EvidenceCall | undefined } {
499
+ const empty = { text: '', call: undefined }
500
+ if (budget < 64) return empty
304
501
  let latest: EvidenceCall | undefined
305
502
  for (const pair of index.calls.values()) {
306
- if (BOOKKEEPING_TOOLS.has(pair.name) || !pair.text.trim()) continue
503
+ if (!isEvidenceOutput(pair.name, pair.text)) continue
307
504
  if (pair.resultSeq <= seq && (latest === undefined || pair.resultSeq > latest.resultSeq)) latest = pair
308
505
  }
309
- if (latest === undefined) return ''
506
+ if (latest === undefined) return empty
310
507
  // The prefix length depends on the tool name, so measure it instead of assuming a
311
508
  // fixed overhead: with a long tool name the old "- 60" let the rendered step exceed
312
509
  // maxItemChars, and boundDecision() then dropped the whole track decision silently.
313
510
  const prefix = current
314
511
  ? '\n\nLatest observed tool output at routing time (' + latest.name + '):\n'
315
512
  : '\n\nLatest observed tool output before this checkpoint (' + latest.name + '):\n'
316
- if (prefix.length >= budget) return ''
317
- return prefix + sanitizeVerifierText(latest.text, budget - prefix.length)
513
+ if (prefix.length >= budget) return empty
514
+ return { text: prefix + sanitizeVerifierText(latest.text, budget - prefix.length), call: latest }
318
515
  }
319
516
 
320
517
  /**
@@ -357,7 +554,11 @@ interface CheckpointSource { seq: number; label: string; body: string }
357
554
  * the CURRENT state: its evidence is the newest observed output in the task rather
358
555
  * than the newest one before the last todo snapshot (which is often several tool calls
359
556
  * stale), and it carries the agent's latest prose as an explicitly labelled claim (see
360
- * {@link currentNarration}), because prose deliverables never reach a tool.
557
+ * {@link currentNarration}), because prose deliverables never reach a tool. It gets one
558
+ * extra slots as well — the newest verification run in the task
559
+ * ({@link verificationEvidence}) and a one-line digest of the recent calls
560
+ * ({@link recentEvidenceDigest}) — because a single output slot cannot show both the
561
+ * tail of the task and the test run that tail is hiding.
361
562
  * @param index - evidence index of the current task.
362
563
  * @param sources - checkpoints in chronological order.
363
564
  * @param maxItemChars - hard per-item cap enforced by boundDecision().
@@ -379,8 +580,20 @@ function renderCheckpointSteps(index: EvidenceIndex, sources: readonly Checkpoin
379
580
  // never reaches a tool.
380
581
  const isCurrent = position === kept.length - 1
381
582
  const observed = checkpointEvidence(index, isCurrent ? Number.POSITIVE_INFINITY : source.seq, isCurrent ? Math.floor(evidenceBudget / 2) : evidenceBudget, isCurrent)
382
- const narration = isCurrent ? currentNarration(index, evidenceBudget - observed.length) : ''
383
- return sanitizeVerifierText(note + source.label + source.body, Math.max(1, stepCap - observed.length - narration.length)) + observed + narration
583
+ // Only the newest checkpoint can be short of verification: the older ones describe
584
+ // past states, and their single output is what was current for them then.
585
+ const verification = isCurrent
586
+ ? verificationEvidence(index, observed.call, Math.floor(evidenceBudget / 2) - observed.text.length)
587
+ : { text: '', call: undefined }
588
+ // The digest comes out of the item cap rather than the evidence budget: it is a
589
+ // bounded summary, and the narration keeps the room the evidence budget reserved for
590
+ // it. MAX_DIGEST_LINES lines of ~110 characters never exceed a tenth of the item.
591
+ const digest = isCurrent
592
+ ? recentEvidenceDigest(index, Math.min(1000, Math.floor(stepCap / 10)), [observed.call, verification.call])
593
+ : ''
594
+ const narration = isCurrent ? currentNarration(index, evidenceBudget - observed.text.length - verification.text.length) : ''
595
+ const used = observed.text.length + verification.text.length + digest.length + narration.length
596
+ return sanitizeVerifierText(note + source.label + source.body, Math.max(1, stepCap - used)) + observed.text + verification.text + digest + narration
384
597
  })
385
598
  return { steps, evidenceSeqs: kept.map(source => source.seq), omitted }
386
599
  }
@@ -488,9 +701,10 @@ export function buildSemanticRoutePrompt(problem: string, events: readonly Sessi
488
701
  let used = 0
489
702
  let omitted = 0
490
703
  for (const [callId, pair] of [...index.calls.entries()].reverse()) {
491
- // Bookkeeping results are not alternatives to anything; offering them let the
492
- // classifier cite e.g. two goal/agent-control calls as competing candidates.
493
- if (BOOKKEEPING_TOOLS.has(pair.name)) continue
704
+ // Bookkeeping and coordination results are not alternatives to anything; offering
705
+ // them let the classifier cite e.g. two goal/agent-control calls as competing
706
+ // candidates, or a previous verdict as an artifact of work.
707
+ if (!isEvidenceOutput(pair.name, pair.text)) continue
494
708
  const text = sanitizeVerifierText(pair.text, maxItemChars)
495
709
  if (artifacts.length > 0 && used + text.length > maxInputChars) { omitted += 1; continue }
496
710
  used += text.length
@@ -548,10 +762,10 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
548
762
  const perItem = itemBudget(output.candidateCallIds.length, maxItemChars, maxInputChars)
549
763
  const candidates = output.candidateCallIds.map((callId, i) => {
550
764
  const pair = index.calls.get(callId)
551
- // Bookkeeping calls are not candidates (they were never offered to the classifier);
765
+ // Non-evidence calls are not candidates (they were never offered to the classifier);
552
766
  // a citation of one is an invalid reference, so the whole decision is rejected
553
767
  // instead of comparing metadata as if it were alternative work.
554
- if (!pair || BOOKKEEPING_TOOLS.has(pair.name)) return undefined
768
+ if (!pair || !isEvidenceOutput(pair.name, pair.text)) return undefined
555
769
  return { id: callId, groupId: 'semantic', label: pair.name + ' ' + (i + 1), content: sanitizeVerifierText(pair.text, perItem), callId, fromSeq: pair.callSeq, toSeq: pair.resultSeq }
556
770
  }).filter((candidate): candidate is CandidateArtifact => candidate !== undefined)
557
771
  if (candidates.length !== output.candidateCallIds.length) return undefined
@@ -581,13 +795,18 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
581
795
  * cancels that.
582
796
  *
583
797
  * `select` does not need it: its ring is symmetric by construction and the pivot round
584
- * is oriented per pair by the engine, so one round is already unbiased. `track` scores
585
- * a single checkpoint list and has no slots at all.
798
+ * is oriented per pair by the engine, so one round is already unbiased. `track` has no
799
+ * slots at all, but it has its own repeat count: without token logprobs one call yields
800
+ * ONE sampled letter (5.3% of the scale per letter), so repeats are averaged to keep the
801
+ * progress curve from flipping bands on sampling noise — the same reason the upstream
802
+ * `n_evaluations` averages repeated verifications.
586
803
  * @param decision - the routed decision about to run.
587
804
  * @param configured - the configured auto-route repeat count.
805
+ * @param trackRepeats - repeat count for a `track` route; defaults to `configured`.
588
806
  * @returns The repeat count to pass to the engine.
589
807
  */
590
- export function routedRepeats(decision: RouteDecision, configured: number): number {
808
+ export function routedRepeats(decision: RouteDecision, configured: number, trackRepeats = configured): number {
809
+ if (decision.kind === 'track') return Math.max(1, trackRepeats)
591
810
  if (decision.kind !== 'compare') return configured
592
811
  return configured % 2 === 0 ? configured : configured + 1
593
812
  }
@@ -621,8 +840,9 @@ export class AutoVerifierRouter {
621
840
  const taskStartSeq = latestDirectUserSeq(sessionEvents(agent.session))
622
841
  if (taskStartSeq === undefined) return undefined
623
842
  const id = String(agent.id)
624
- const state = this.states.get(id) ?? { taskStartSeq, taskAttempts: 0, sessionAttempts: 0, taskModelCalls: 0, sessionModelCalls: 0, completed: new Set(), failed: new Set(), strictBlocked: false }
625
- if (state.taskStartSeq !== taskStartSeq) { state.taskStartSeq = taskStartSeq; state.taskAttempts = 0; state.taskModelCalls = 0; state.completed.clear(); state.failed.clear(); state.inFlight = undefined; state.finalRequiredFromSeq = undefined; state.strictBlocked = false; this.exhaustedNotices.delete(id) }
843
+ const state = this.states.get(id) ?? { taskStartSeq, routeAttempts: 0, finalAttempts: 0, sessionRouteAttempts: 0, sessionFinalAttempts: 0, taskModelCalls: 0, sessionModelCalls: 0, completed: new Set(), failed: new Set(), finalPreferred: false, strictBlocked: false }
844
+ // A new task resets its own attempt and model-call counters, but never the session counters.
845
+ if (state.taskStartSeq !== taskStartSeq) { state.taskStartSeq = taskStartSeq; state.routeAttempts = 0; state.finalAttempts = 0; state.taskModelCalls = 0; state.completed.clear(); state.failed.clear(); state.inFlight = undefined; state.finalRequiredFromSeq = undefined; state.finalPreferred = false; state.strictBlocked = false; this.exhaustedNotices.delete(id) }
626
846
  this.states.set(id, state)
627
847
  return state
628
848
  }
@@ -630,9 +850,27 @@ export class AutoVerifierRouter {
630
850
  reserve(agent: RoutedAgent, phase: RoutePhase, fingerprint: string, expectedCalls: number, policy: RouterPolicy): Reservation | undefined {
631
851
  if (policy.mode === 'manual') return undefined
632
852
  const state = this.state(agent)
633
- if (!state || state.inFlight || state.completed.has(fingerprint) || state.taskAttempts >= policy.maxPerTask || state.sessionAttempts >= policy.maxPerSession || state.taskModelCalls + expectedCalls > policy.maxModelCallsPerTask || state.sessionModelCalls + expectedCalls > policy.maxModelCallsPerSession) return undefined
853
+ if (!state || state.inFlight || state.completed.has(fingerprint)) return undefined
854
+ // Routing and the final gate draw on separate attempt counters, so no amount of
855
+ // routing can leave the mandatory acceptance without an attempt of its own.
856
+ const final = phase === 'final'
857
+ const attempts = final ? state.finalAttempts : state.routeAttempts
858
+ const sessionAttempts = final ? state.sessionFinalAttempts : state.sessionRouteAttempts
859
+ const maxTask = final ? policy.maxFinalPerTask : policy.maxRoutePerTask
860
+ const maxSession = final ? policy.maxFinalPerSession : policy.maxRoutePerSession
861
+ if (attempts >= maxTask || sessionAttempts >= maxSession || state.taskModelCalls + expectedCalls > policy.maxModelCallsPerTask || state.sessionModelCalls + expectedCalls > policy.maxModelCallsPerSession) return undefined
634
862
  const reservation = { id: String(++this.serial), phase, fingerprint, taskStartSeq: state.taskStartSeq }
635
- state.inFlight = reservation; state.taskAttempts++; state.sessionAttempts++; state.taskModelCalls += expectedCalls; state.sessionModelCalls += expectedCalls
863
+ state.inFlight = reservation
864
+ if (final) {
865
+ state.finalAttempts += 1; state.sessionFinalAttempts += 1
866
+ // The reservation honors the preference; a later failure must not keep routing
867
+ // disabled for the rest of the task.
868
+ state.finalPreferred = false
869
+ } else {
870
+ state.routeAttempts += 1; state.sessionRouteAttempts += 1
871
+ }
872
+ state.taskModelCalls += expectedCalls
873
+ state.sessionModelCalls += expectedCalls
636
874
  return reservation
637
875
  }
638
876
 
@@ -644,7 +882,7 @@ export class AutoVerifierRouter {
644
882
  // a full session verification at the very next stop boundary, before anything was built
645
883
  // (and, in strict mode, burn an attempt and set strictBlocked on that empty review).
646
884
  if (reservation.phase !== 'semantic' && reservation.phase !== 'final' && reservation.phase !== 'plan_review') state.finalRequiredFromSeq = Math.max(state.finalRequiredFromSeq ?? 0, evidenceSeq ?? reservation.taskStartSeq)
647
- if (reservation.phase === 'final') state.finalRequiredFromSeq = undefined
885
+ if (reservation.phase === 'final') { state.finalRequiredFromSeq = undefined; state.finalPreferred = false }
648
886
  return true
649
887
  }
650
888
 
@@ -652,6 +890,7 @@ export class AutoVerifierRouter {
652
890
  const state = this.state(agent)
653
891
  if (!state || state.inFlight?.id !== reservation.id) return
654
892
  state.inFlight = undefined; state.failed.add(reservation.fingerprint); if (strict) state.strictBlocked = true
893
+ if (reservation.phase === 'final') state.finalPreferred = false
655
894
  }
656
895
 
657
896
  /**
@@ -679,7 +918,7 @@ export class AutoVerifierRouter {
679
918
  budgetExhausted(agent: RoutedAgent, expectedCalls: number, policy: RouterPolicy): boolean {
680
919
  const state = this.state(agent)
681
920
  if (!state) return true
682
- return state.taskAttempts >= policy.maxPerTask || state.sessionAttempts >= policy.maxPerSession
921
+ return state.routeAttempts >= policy.maxRoutePerTask || state.sessionRouteAttempts >= policy.maxRoutePerSession
683
922
  || state.taskModelCalls + expectedCalls > policy.maxModelCallsPerTask || state.sessionModelCalls + expectedCalls > policy.maxModelCallsPerSession
684
923
  }
685
924
 
@@ -687,6 +926,17 @@ export class AutoVerifierRouter {
687
926
  completedFingerprint(agent: RoutedAgent, fingerprint: string): boolean { return this.state(agent)?.completed.has(fingerprint) ?? false }
688
927
 
689
928
  finalRequired(agent: RoutedAgent): number | undefined { return this.state(agent)?.finalRequiredFromSeq }
929
+
930
+ /**
931
+ * Arm "run the final gate next": a track route already cleared the completion threshold,
932
+ * so the next stop boundary must not buy another route first.
933
+ * @param agent - Agent whose track route cleared the threshold.
934
+ */
935
+ preferFinal(agent: RoutedAgent): void { const state = this.state(agent); if (state) state.finalPreferred = true }
936
+
937
+ /** Whether the next stop boundary must skip routing and run the final gate. */
938
+ finalPreferred(agent: RoutedAgent): boolean { return this.state(agent)?.finalPreferred ?? false }
939
+
690
940
  strictBlocked(agent: RoutedAgent): boolean { return this.state(agent)?.strictBlocked ?? false }
691
941
  release(agent: { id: unknown }): void { this.states.delete(String(agent.id)); this.exhaustedNotices.delete(String(agent.id)) }
692
942
  }
@@ -44,6 +44,12 @@ describe('StatisticsStore', () => {
44
44
  expect(all.totals).toMatchObject({ invocations: 2, successes: 1, failures: 1, calls: 3, attempts: 4, retries: 1, tokens: 185, cacheHits: 1, cacheMisses: 2 })
45
45
  expect(all.totals.successRate).toBe(0.5)
46
46
  expect(all.totals.averageDurationMs).toBe(500)
47
+ // Provider prefix cache: 20 of 170 input tokens came from cache. DSH reports cached and
48
+ // uncached input as DISJOINT counts, so the denominator is their sum (that is also how
49
+ // tokens() totals them). This is a different quantity from the score-cache hit rate
50
+ // (1 of 3 lookups) and the two must never be conflated.
51
+ expect(all.totals.prefixCacheHitRate).toBeCloseTo(20 / 170, 10)
52
+ expect(all.totals.cacheHitRate).toBeCloseTo(1 / 3, 10)
47
53
  expect(all.daily).toHaveLength(1)
48
54
  expect(all.daily[0]).toMatchObject({ date: '2026-08-20', invocations: 2, calls: 3, tokens: 185 })
49
55
  expect(all.tools.map(row => [row.toolName, row.invocations])).toEqual([['verifier_compare', 1], ['verifier_track', 1]])
@@ -55,6 +61,14 @@ describe('StatisticsStore', () => {
55
61
  expect(JSON.parse(await readFile(file, 'utf8')).version).toBe(1)
56
62
  })
57
63
 
64
+ it('reports a zero prefix cache rate for an empty range instead of NaN', async () => {
65
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
66
+ const store = new StatisticsStore(join(root, 'statistics.json'), 100)
67
+ const empty = await store.overview({ fromMs: 0, toMs: 1 })
68
+ expect(empty.totals.prefixCacheHitRate).toBe(0)
69
+ expect(empty.totals.cacheHitRate).toBe(0)
70
+ })
71
+
58
72
  it('serializes concurrent writes and keeps only the configured tail', async () => {
59
73
  const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
60
74
  const store = new StatisticsStore(join(root, 'statistics.json'), 2)
@@ -196,6 +210,9 @@ describe('StatisticsStore', () => {
196
210
  expect(summarizeVerdict('verifier_route_classify', { kind: 'none' }, 'semantic', thresholds)).toEqual({ phase: 'semantic', outcome: 'classified' })
197
211
  // Select reports the ranked winner's score.
198
212
  expect(summarizeVerdict('verifier_select', { index: 1, scores: [0.2, 0.9], ranking: [1, 0] }, 'select', thresholds)).toEqual({ phase: 'select', outcome: 'ranked', score: 0.9 })
213
+ // A candidate list whose entries were all byte-identical was never judged: say so instead
214
+ // of reporting index 0's 0.5 as if the tournament had ranked it.
215
+ expect(summarizeVerdict('verifier_select', { index: 0, scores: [0.5, 0.5], identical: true }, 'select', thresholds)).toEqual({ phase: 'select', outcome: 'identical-candidates' })
199
216
  // Track reports the newest checkpoint (not the historical minimum) plus the curve.
200
217
  expect(summarizeVerdict('verifier_track', { scores: [0, 0.10526315789473684, 0.7894736842105262] }, 'track', thresholds)).toEqual({
201
218
  phase: 'track',
@@ -205,6 +222,26 @@ describe('StatisticsStore', () => {
205
222
  threshold: 0.8,
206
223
  })
207
224
  expect(summarizeVerdict('verifier_track', { scores: [0.9] }, 'track', thresholds)).toEqual({ phase: 'track', outcome: 'passed', score: 0.9, threshold: 0.8 })
225
+ // Best-of-N is a GATE-shaped acceptance, not a select ranking: its scores array holds
226
+ // relative tournament shares and must never be read as the absolute score, while
227
+ // score/baselineScore/criteria come from the same empty-work comparison the gate runs.
228
+ expect(summarizeVerdict('verifier_best_of_n', {
229
+ scores: [0.9, 0.7, 0.4],
230
+ index: 0,
231
+ score: 0.66,
232
+ baselineScore: 0,
233
+ winner: 'A',
234
+ criteria: [{ id: 'specification', scoreA: 0.7 }, { id: 'output_match', scoreA: 0.62 }],
235
+ }, 'explicit', thresholds)).toEqual({
236
+ phase: 'explicit',
237
+ outcome: 'below-threshold',
238
+ score: 0.66,
239
+ baselineScore: 0,
240
+ criteria: [{ id: 'specification', score: 0.7 }, { id: 'output_match', score: 0.62 }],
241
+ winner: 'A',
242
+ threshold: 0.65,
243
+ })
244
+ expect(summarizeVerdict('verifier_best_of_n', { score: 0.9, baselineScore: 0, winner: 'A', criteria: [{ id: 'a', scoreA: 1 }] }, 'explicit', thresholds)).toMatchObject({ outcome: 'passed', score: 0.9, threshold: 0.65 })
208
245
  })
209
246
 
210
247
  it('reports the winning side of a comparison instead of the loser under a threshold', () => {
@@ -214,6 +251,8 @@ describe('StatisticsStore', () => {
214
251
  expect(summarizeVerdict('verifier_compare', { scoreA: 0.2, scoreB: 0.9, winner: 'B' }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'compared', score: 0.9, scoreB: 0.9, winner: 'B' })
215
252
  expect(summarizeVerdict('verifier_compare', { scoreA: 0.9, scoreB: 0.2, winner: 'A' }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'compared', score: 0.9, scoreB: 0.2, winner: 'A' })
216
253
  expect(summarizeVerdict('verifier_compare', { scoreA: 0.5, scoreB: 0.5, winner: 'tie' }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'tie', score: 0.5, scoreB: 0.5, winner: 'tie' })
254
+ // Byte-identical sides: distinguishable from a judged tie, because no call was made.
255
+ expect(summarizeVerdict('verifier_compare', { scoreA: 0.5, scoreB: 0.5, winner: 'tie', identical: true }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'identical', score: 0.5, scoreB: 0.5, winner: 'tie' })
217
256
  // Session acceptance keeps A = the session and B = the empty-work baseline.
218
257
  expect(summarizeVerdict('verifier_current_session', { score: 0.4, baselineScore: 0, winner: 'A' }, 'final', thresholds)).toEqual({
219
258
  phase: 'final', outcome: 'below-threshold', score: 0.4, baselineScore: 0, winner: 'A', threshold: 0.65,
@@ -224,7 +263,10 @@ describe('StatisticsStore', () => {
224
263
  // Losing to the baseline can never pass, however high the raw score is.
225
264
  expect(summarizeVerdict('verifier_current_session', { score: 0.9, baselineScore: 0.95, winner: 'B' }, 'final', thresholds)).toMatchObject({ outcome: 'below-threshold' })
226
265
  // One criterion below the threshold fails the record even though the mean passes.
227
- expect(summarizeVerdict('verifier_current_session', { score: 0.6667, baselineScore: 0, winner: 'A', criteria: [{ id: 'specification', scoreA: 1 }, { id: 'error_signals', scoreA: 0 }] }, 'final', thresholds)).toEqual({
266
+ // This case uses the EXACT shape `verifySession` returns (AcceptanceCriterion: {id, name,
267
+ // score}) — a previous version of this test fed {id, scoreA}, which is the compare shape, so
268
+ // it passed while every real session acceptance stored an empty criteria array.
269
+ expect(summarizeVerdict('verifier_current_session', { score: 0.6667, baselineScore: 0, winner: 'A', criteria: [{ id: 'specification', name: 'Specification Adherence', score: 1 }, { id: 'error_signals', name: 'Error Signal Detection', score: 0 }] }, 'final', thresholds)).toEqual({
228
270
  phase: 'final',
229
271
  outcome: 'below-threshold',
230
272
  score: 0.6667,
@@ -233,6 +275,12 @@ describe('StatisticsStore', () => {
233
275
  winner: 'A',
234
276
  threshold: 0.65,
235
277
  })
278
+ // The compare-shaped scoreA is still accepted, and a malformed entry is dropped rather than
279
+ // stored as NaN.
280
+ expect(summarizeVerdict('verifier_current_session', { score: 0.9, baselineScore: 0, winner: 'A', criteria: [{ id: 'legacy', scoreA: 0.4 }, { id: 'broken', score: 'high' }] }, 'final', thresholds)).toMatchObject({
281
+ outcome: 'below-threshold',
282
+ criteria: [{ id: 'legacy', score: 0.4 }],
283
+ })
236
284
  })
237
285
 
238
286
  it('keeps the track checkpoint progression and drops invalid entries', async () => {