dsh-llm-verifier 0.6.0 → 0.6.2

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 (61) hide show
  1. package/README.md +3 -0
  2. package/lib/{auto-DAMeKRkK.js → auto-caXDU2pw.js} +19 -1
  3. package/lib/{auto-DAMeKRkK.js.map → auto-caXDU2pw.js.map} +1 -1
  4. package/lib/client.js +493 -331
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +201 -52
  7. package/lib/index.js.map +1 -1
  8. package/lib/replay.js +1 -1
  9. package/lib/types/client-i18n.d.ts +36 -11
  10. package/lib/types/client-i18n.d.ts.map +1 -1
  11. package/lib/types/client-i18n.js +50 -9
  12. package/lib/types/client-i18n.js.map +1 -1
  13. package/lib/types/client.d.ts +17 -7
  14. package/lib/types/client.d.ts.map +1 -1
  15. package/lib/types/client.js +165 -44
  16. package/lib/types/client.js.map +1 -1
  17. package/lib/types/decisions.d.ts +8 -0
  18. package/lib/types/decisions.d.ts.map +1 -1
  19. package/lib/types/decisions.js +13 -1
  20. package/lib/types/decisions.js.map +1 -1
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +47 -31
  23. package/lib/types/index.js.map +1 -1
  24. package/lib/types/process-selection.d.ts +27 -5
  25. package/lib/types/process-selection.d.ts.map +1 -1
  26. package/lib/types/process-selection.js +17 -8
  27. package/lib/types/process-selection.js.map +1 -1
  28. package/lib/types/router.d.ts +22 -0
  29. package/lib/types/router.d.ts.map +1 -1
  30. package/lib/types/router.js +20 -0
  31. package/lib/types/router.js.map +1 -1
  32. package/lib/types/statistics.d.ts +8 -0
  33. package/lib/types/statistics.d.ts.map +1 -1
  34. package/lib/types/statistics.js +1 -1
  35. package/lib/types/statistics.js.map +1 -1
  36. package/lib/types/verifier-activity.d.ts +189 -0
  37. package/lib/types/verifier-activity.d.ts.map +1 -0
  38. package/lib/types/verifier-activity.js +223 -0
  39. package/lib/types/verifier-activity.js.map +1 -0
  40. package/package.json +1 -1
  41. package/src/client-i18n.ts +55 -16
  42. package/src/client.test.ts +101 -16
  43. package/src/client.tsx +238 -103
  44. package/src/decisions.test.ts +16 -0
  45. package/src/decisions.ts +22 -1
  46. package/src/index.test.ts +32 -0
  47. package/src/index.ts +48 -33
  48. package/src/process-selection.test.ts +13 -0
  49. package/src/process-selection.ts +39 -14
  50. package/src/router.test.ts +46 -0
  51. package/src/router.ts +31 -0
  52. package/src/statistics.test.ts +16 -0
  53. package/src/statistics.ts +9 -1
  54. package/src/verifier-activity.test.ts +164 -0
  55. package/src/verifier-activity.ts +321 -0
  56. package/lib/types/process-activity.d.ts +0 -109
  57. package/lib/types/process-activity.d.ts.map +0 -1
  58. package/lib/types/process-activity.js +0 -135
  59. package/lib/types/process-activity.js.map +0 -1
  60. package/src/process-activity.test.ts +0 -91
  61. package/src/process-activity.ts +0 -177
@@ -113,6 +113,22 @@ describe('DecisionStore', () => {
113
113
  expect(document.records).toHaveLength(1)
114
114
  })
115
115
 
116
+ it('files a snapshot under the caller\'s invocation id, so the statistics row can resolve it', async () => {
117
+ // Regression: the dashboard lists statistics rows and asks for a row's snapshot BY the id it
118
+ // listed the row under. Two independently generated uuids made every lookup miss, and the panel
119
+ // then blamed pruning or a deleted topic while the snapshot sat in this very file.
120
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
121
+ const file = join(root, 'decisions.json')
122
+ const store = new DecisionStore(file)
123
+ const record = await store.record({ id: 'invocation-7', toolName: 'verifier_current_session', phase: 'final', startedAt: 1, provider: 'p', model: 'm', calls: [call()] })
124
+ expect(record?.id).toBe('invocation-7')
125
+ expect(await store.find('invocation-7')).toMatchObject({ toolName: 'verifier_current_session' })
126
+ // An unusable id falls back to a fresh uuid instead of merging two invocations.
127
+ const fallback = await store.record({ id: '', toolName: 'verifier_track', phase: 'track', startedAt: 2, provider: 'p', model: 'm', calls: [call()] })
128
+ expect(fallback?.id).toBeTruthy()
129
+ expect(fallback?.id).not.toBe('')
130
+ })
131
+
116
132
  it('writes nothing when no call was captured', async () => {
117
133
  const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
118
134
  const file = join(root, 'decisions.json')
package/src/decisions.ts CHANGED
@@ -148,6 +148,14 @@ export interface DecisionInput {
148
148
  provider: string
149
149
  model: string
150
150
  calls: readonly DecisionCall[]
151
+ /**
152
+ * Invocation id to file the snapshot under; a fresh uuid when omitted.
153
+ *
154
+ * The dashboard lists STATISTICS rows and asks for the snapshot of one row by ITS id, so the
155
+ * caller passes the id the statistics row was stored with. Two independent uuids made every
156
+ * lookup miss and the panel claim the snapshot was pruned or never captured.
157
+ */
158
+ id?: string
151
159
  }
152
160
 
153
161
  /**
@@ -171,7 +179,7 @@ export class DecisionStore {
171
179
  const calls = boundDecisionCalls(input.calls)
172
180
  if (calls.length === 0) return undefined
173
181
  const record: DecisionRecord = {
174
- id: randomUUID(),
182
+ id: idOf(input.id),
175
183
  toolName: input.toolName,
176
184
  phase: input.phase,
177
185
  startedAt: input.startedAt,
@@ -228,6 +236,19 @@ export class DecisionStore {
228
236
  }
229
237
  }
230
238
 
239
+ /**
240
+ * The id a snapshot is filed under.
241
+ *
242
+ * A caller-supplied id is the statistics row's own id ({@link DecisionInput.id}), which is what
243
+ * makes the dashboard's "one snapshot per row" lookup resolve; anything unusable falls back to a
244
+ * fresh uuid so a malformed argument can never merge two invocations into one record.
245
+ * @param candidate - id supplied by the caller, if any.
246
+ * @returns The id to store the record under.
247
+ */
248
+ function idOf(candidate: string | undefined): string {
249
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : randomUUID()
250
+ }
251
+
231
252
  /**
232
253
  * Loose validation, so a record written by a newer/older plugin still loads.
233
254
  * @param value - parsed JSON entry.
package/src/index.test.ts CHANGED
@@ -723,6 +723,38 @@ describe('usage diagnostics in tool outputs', () => {
723
723
  })
724
724
  })
725
725
 
726
+ /**
727
+ * The dashboard's "why did the judge decide that" path.
728
+ *
729
+ * The statistics row and its decision snapshot must share ONE id: the row is listed under it, the
730
+ * snapshot is filed under it, and the decision endpoint resolves "the snapshot of this row" by
731
+ * exactly that value. Two independently generated uuids made every click answer "pruned, never
732
+ * captured, or belongs to a deleted topic" while the snapshot sat in the topic sidecar.
733
+ */
734
+ describe('decision snapshots behind a dashboard row', () => {
735
+ it('resolves the snapshot from the invocation id the row was listed under', async () => {
736
+ const { tools, rpc } = assemble(JUDGE, {
737
+ stream: () => textStream('reasoning\n<score_A> A </score_A>\n<score_B> T </score_B>'),
738
+ sessions: [{ id: 'session-1', createdAt: 1 }],
739
+ })
740
+ await tools.get('verifier_compare')!.execute({ problem: 'pick the better plan', candidate_a: 'AAA', candidate_b: 'BBB', repeats: 1 }, exec)
741
+ const handler = rpc.get('/llm-verifier')!
742
+ const overview = await handler('statistics', { fromMs: 0, toMs: Date.now() + 60_000 }) as { value: { recent: Array<{ id: string; toolName: string; success: boolean }> } }
743
+ const row = overview.value.recent.find(entry => entry.success)
744
+ expect(row).toBeDefined()
745
+
746
+ const found = await handler('decision', { id: row!.id }) as { ok: boolean; value?: { decision: { id: string; toolName: string; calls: Array<{ prompt: string; output: string }> } } }
747
+ expect(found.ok).toBe(true)
748
+ expect(found.value?.decision.id).toBe(row!.id)
749
+ expect(found.value?.decision.toolName).toBe('verifier_compare')
750
+ expect(found.value?.decision.calls.length).toBeGreaterThan(0)
751
+ expect(found.value?.decision.calls[0]?.prompt).toContain('pick the better plan')
752
+
753
+ // A genuinely unknown id still fails closed instead of inventing a snapshot.
754
+ expect(await handler('decision', { id: 'never-recorded' })).toMatchObject({ ok: false })
755
+ })
756
+ })
757
+
726
758
  /**
727
759
  * The turn-stopping gate is the one place a manual pass would previously be honoured even
728
760
  * when it reviewed only an older slice of the task. These drive the REAL registered hook
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ import { extractSession, sanitizeVerifierText, sessionEvents } from './session.t
15
15
  import { CriteriaResolver, type ResolvedCriteria } from './criteria.ts'
16
16
  import { analyzeAutoTask, automaticFeedback, compareRouteFeedbackDetail, failedAcceptanceCriteria, isSubagentSession, selectRouteFeedbackDetail, sessionAccepted, MAX_ROUTE_FEEDBACK_CHARS, type AcceptanceCriterion, type RoutedCandidateRef } from './auto.ts'
17
17
  import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRouteView, estimateRoutedCalls, inspectDeliveryPhase, inspectRecoverySignal, latestDirectUserSeq, nextDiagnosticCycleId, parseSemanticRoute, routedRepeats, semanticDecision, semanticReferencesVisible, semanticRouteHint, type CandidateArtifact, type Reservation, type RouteDecision, type RoutedVerifierKind, type SemanticRouteView } from './router.ts'
18
- import type { ProcessActivityView } from './process-activity.ts'
18
+ import { VerifierActivities, createActivityObserver, type ActivityView } from './verifier-activity.ts'
19
19
  import { ProcessCycleStore, ProcessSelector, resolveProcessFile, type ProcessCycleReport } from './process-selection.ts'
20
20
  import { DEFAULT_GROUND_TRUTH_NOTE, EMPTY_WORK_BASELINE, PROPOSAL_CRITERIA, buildGenerationPrompt, buildPairwisePrompt, extractScore, renderDiagnostics, renderReferenceContext, type Diagnostic, type ReviewStage } from './core.ts'
21
21
  import { buildPlanPreReviewPrompt, parseVerdictLetter, planFromArguments } from './plan-gate.ts'
@@ -198,7 +198,11 @@ export function apply(ctx: Context, config: Config = {}): void {
198
198
  // this closure, so the callback goes through a holder instead of capturing a TDZ binding.
199
199
  let clearProcessIntents: () => void = () => {}
200
200
  const current = installVerifierSettings(ctx, entry, () => { limiter = new RequestLimiter(current().maxConcurrency); clearProcessIntents() })
201
- const autoRouter = new AutoVerifierRouter()
201
+ // ONE activity table per plugin instance: the router publishes every granted cycle into it, P06
202
+ // adds its own richer records, and the chat chip's RPC reads it. It is UI state only — never a
203
+ // verdict, never the model history (see `verifier-activity.ts` for why it is not a session event).
204
+ const activities = new VerifierActivities()
205
+ const autoRouter = new AutoVerifierRouter(createActivityObserver(activities))
202
206
  const topics = new Map<string, { dataDir: string; cache: ScoreCache; capabilities: TopLogprobCapabilityCache; flights: SingleFlight<{ value: CachedPairScore; hit: boolean }>; statistics: StatisticsStore; decisions: DecisionStore; process: ProcessCycleStore }>()
203
207
  const topic = (header: SessionHeader) => {
204
208
  const selected = current()
@@ -326,11 +330,15 @@ export function apply(ctx: Context, config: Config = {}): void {
326
330
  const completed = await operation(trace)
327
331
  selected = completed.selected
328
332
  const value = { ...completed.result as T & object, ...route(selected) } as T & { provider: string; model: string }
329
- await statistics.record({ toolName, sessionId: String(agent.id), startedAt, success: true, provider: selected.provider, model: selected.model, stats: statsFrom(value), verdict: verdictFrom(toolName, value, phase), ...(observation ? { route: observation } : {}) }).catch(() => {})
333
+ // The statistics row and its decision snapshot share ONE id: the dashboard lists statistics
334
+ // rows and asks for "the snapshot of this row" by the id it was listed under. Two
335
+ // independently generated uuids made every lookup miss, and the panel then reported the
336
+ // snapshot as pruned / never captured even though it was sitting in the topic's sidecar.
337
+ const invocation = await statistics.record({ toolName, sessionId: String(agent.id), startedAt, success: true, provider: selected.provider, model: selected.model, stats: statsFrom(value), verdict: verdictFrom(toolName, value, phase), ...(observation ? { route: observation } : {}) }).catch(() => undefined)
330
338
  // Order by label before storing: the engine reports calls as they complete, so a concurrent
331
339
  // fan-out reports them in network order. Sorting makes "which calls are in the snapshot"
332
340
  // reproducible even when the record has to bound its text.
333
- if (calls.length > 0) await topicEntry.decisions.record({ toolName, phase, startedAt, provider: selected.provider, model: selected.model, calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))) }).catch(() => {})
341
+ if (calls.length > 0) await topicEntry.decisions.record({ ...(invocation === undefined ? {} : { id: invocation.id }), toolName, phase, startedAt, provider: selected.provider, model: selected.model, calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))) }).catch(() => {})
334
342
  return value
335
343
  } catch (error) {
336
344
  const details = errorDetails(error)
@@ -754,6 +762,7 @@ export function apply(ctx: Context, config: Config = {}): void {
754
762
  sanitize: (text, maxChars) => sanitizeVerifierText(text, maxChars),
755
763
  policy: processPolicy,
756
764
  router: () => autoRouter,
765
+ activities,
757
766
  store: agent => topic((agent as Agent).session.header).process,
758
767
  // The whole task, not just the user's question: the judge has to judge whether the next step
759
768
  // addresses the REAL failure, which needs the constraints the request was made under, the
@@ -785,17 +794,18 @@ export function apply(ctx: Context, config: Config = {}): void {
785
794
  }, request.signal)
786
795
  // The decision snapshot is written here, but the STATISTICS row is written once for the whole
787
796
  // cycle (see record below): a second row would count the same judge calls twice.
788
- if (calls.length > 0) {
789
- await topic(agent.session.header).decisions.record({
790
- toolName: 'verifier_compare',
791
- phase: 'process',
792
- startedAt,
793
- provider: selected.provider,
794
- model: selected.model,
795
- calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))),
796
- }).catch(() => {})
797
- }
798
- return result
797
+ const snapshot = calls.length === 0 ? undefined : await topic(agent.session.header).decisions.record({
798
+ toolName: 'verifier_compare',
799
+ phase: 'process',
800
+ startedAt,
801
+ provider: selected.provider,
802
+ model: selected.model,
803
+ calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))),
804
+ }).catch(() => undefined)
805
+ // This cycle's statistics row is written later (once the winner is known), so the snapshot
806
+ // hands its id forward and that row is stored under it: the dashboard asks for a row's
807
+ // snapshot BY the id it listed the row with.
808
+ return snapshot === undefined ? result : { ...result, decisionId: snapshot.id }
799
809
  },
800
810
  // The tournament seam. Optional by construction: it is only reached when autoProcessCandidates
801
811
  // asks for more than two candidates, and a cycle without it compares one pair instead.
@@ -815,17 +825,16 @@ export function apply(ctx: Context, config: Config = {}): void {
815
825
  ...(trace ? { trace } : {}),
816
826
  }, request.signal)
817
827
  // One decision snapshot, one statistics row for the whole cycle — exactly like the pair path.
818
- if (calls.length > 0) {
819
- await topic(agent.session.header).decisions.record({
820
- toolName: 'verifier_select',
821
- phase: 'process',
822
- startedAt,
823
- provider: selected.provider,
824
- model: selected.model,
825
- calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))),
826
- }).catch(() => {})
827
- }
828
- return result
828
+ const snapshot = calls.length === 0 ? undefined : await topic(agent.session.header).decisions.record({
829
+ toolName: 'verifier_select',
830
+ phase: 'process',
831
+ startedAt,
832
+ provider: selected.provider,
833
+ model: selected.model,
834
+ calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))),
835
+ }).catch(() => undefined)
836
+ // Same id hand-off as the pairwise path: one statistics row, one snapshot, one shared id.
837
+ return snapshot === undefined ? result : { ...result, decisionId: snapshot.id }
829
838
  },
830
839
  record: async (report: ProcessCycleReport) => {
831
840
  const agent = report.agent as Agent
@@ -849,6 +858,9 @@ export function apply(ctx: Context, config: Config = {}): void {
849
858
  : { phase: 'process', outcome: report.outcome }
850
859
  const failedCall = report.outcome === 'generation-failed' || report.outcome === 'comparison-failed'
851
860
  await topic(agent.session.header).statistics.record({
861
+ // The snapshot of this cycle was already written while the comparison ran; the row adopts
862
+ // its id so the dashboard can fetch that snapshot from the row it lists.
863
+ ...(report.decisionId === undefined ? {} : { id: report.decisionId }),
852
864
  toolName: report.select === undefined ? 'verifier_compare' : 'verifier_select',
853
865
  sessionId: String(agent.id),
854
866
  startedAt: report.startedAt,
@@ -953,23 +965,26 @@ export function apply(ctx: Context, config: Config = {}): void {
953
965
  const isDecisionQuery = (payload: unknown): boolean => typeof payload === 'object' && payload !== null && (payload as { kind?: unknown }).kind === 'decision'
954
966
 
955
967
  /**
956
- * Whether a payload asks what the process-selection (P06) cycle of one session is doing.
968
+ * Whether a payload asks what this plugin is doing for one session.
957
969
  *
958
- * This is what the chat chip polls while a turn runs. The answer is in-memory state only: no
959
- * sidecar read, no model call, and nothing durable, so polling it cannot cost anything or change
960
- * a verdict. A session that never bought a cycle answers with an empty object.
970
+ * This is what the chat chip polls while a turn runs: a P06 cycle (which buffers the reply), a
971
+ * routed review, or the final acceptance. The wire name is historical — it covers every stage now,
972
+ * and keeping it means a page holding the older client bundle keeps working.
961
973
  */
962
974
  const isProcessQuery = (payload: unknown): boolean => typeof payload === 'object' && payload !== null && (payload as { kind?: unknown }).kind === 'process'
963
975
 
964
976
  /**
965
- * Read the in-flight / just-settled process cycle of one session.
977
+ * Read the in-flight / just-settled cycle of one session.
978
+ *
979
+ * In-memory state only: no sidecar read, no model call, nothing durable, so polling it cannot cost
980
+ * anything or change a verdict. A session with nothing to show answers with an empty object.
966
981
  * @param payload - the RPC payload; `sessionId` is required.
967
982
  * @returns The activity view, or a bad-request failure.
968
983
  */
969
- const handleProcessQuery = async (payload: unknown): Promise<{ ok: true; value: { sessionId: string } & ProcessActivityView } | ReturnType<typeof rpcFailure>> => {
984
+ const handleProcessQuery = async (payload: unknown): Promise<{ ok: true; value: { sessionId: string } & ActivityView } | ReturnType<typeof rpcFailure>> => {
970
985
  const sessionId = (payload as { sessionId?: unknown } | null)?.sessionId
971
986
  if (typeof sessionId !== 'string' || sessionId === '') return rpcFailure('a sessionId is required')
972
- return rpcSuccess({ sessionId, ...processSelector.activity(sessionId) })
987
+ return rpcSuccess({ sessionId, ...activities.read(sessionId, Date.now()) })
973
988
  }
974
989
 
975
990
  /**
@@ -457,6 +457,19 @@ describe('process cycle execution', () => {
457
457
  expect(report?.observation.replayed).toBe('candidate')
458
458
  })
459
459
 
460
+ it('carries the snapshot id the judging seam reported, so the statistics row can link to it', async () => {
461
+ // The seam files the decision snapshot while it judges, but the statistics row only exists once
462
+ // the winner is known: the id travels with the report so that row can adopt it (the dashboard
463
+ // asks for "the snapshot of this row" by the id it listed the row under).
464
+ const linked = harness({ compare: async () => compareResult('B', { decisionId: 'snapshot-1' }) })
465
+ const { report } = await run(linked, { original: textChunks('ORIGINAL') })
466
+ expect(report?.decisionId).toBe('snapshot-1')
467
+
468
+ // Nothing captured (every judge call was a cache hit): the row keeps its own generated id.
469
+ const plain = harness({ compare: async () => compareResult('B') })
470
+ expect((await run(plain, { original: textChunks('ORIGINAL') })).report?.decisionId).toBeUndefined()
471
+ })
472
+
460
473
  it('replays the original verbatim on a tie, on an A win, and records which', async () => {
461
474
  for (const winner of ['A', 'tie'] as const) {
462
475
  const original = textChunks('ORIGINAL')
@@ -22,7 +22,7 @@ import { addUsage, emptyUsage, GENERATION_TEMPERATURE, type UsageStats } from '.
22
22
  import { stableHash } from './cache.ts'
23
23
  import { PROCESS_CRITERIA, type Criterion } from './core.ts'
24
24
  import { mergeRunStats, partialStats, type CompareResult, type RunStats, type SelectResult } from './engine.ts'
25
- import { ProcessActivities, type ProcessActivityView } from './process-activity.ts'
25
+ import { VerifierActivities, classifyProcessOutcome, type ActivityView } from './verifier-activity.ts'
26
26
  import { estimateRoutedCalls, itemBudget, type AutoVerifierRouter, type Reservation, type RouterPolicy, type RoutedAgent } from './router.ts'
27
27
  import type { RouteObservation } from './statistics.ts'
28
28
 
@@ -147,6 +147,14 @@ export interface ProcessCycleReport {
147
147
  compare?: CompareResult
148
148
  /** Present when the cycle ran the tournament (N>2). */
149
149
  select?: SelectResult
150
+ /**
151
+ * Id of the decision snapshot the judging seam filed, when it filed one.
152
+ *
153
+ * The seam writes the snapshot while it judges, but the statistics row only exists once the
154
+ * winner is known. Reporting the id lets that row adopt it, so the dashboard can fetch a row's
155
+ * snapshot by the id it listed the row under.
156
+ */
157
+ decisionId?: string
150
158
  error?: string
151
159
  }
152
160
 
@@ -209,6 +217,13 @@ export interface ProcessSelectorDeps {
209
217
  /** The routing policy in force, including the final-acceptance floor and the process allowance. */
210
218
  policy(): Promise<RouterPolicy>
211
219
  router(): AutoVerifierRouter
220
+ /**
221
+ * Shared activity table the router's cycles and these P06 cycles are published to.
222
+ *
223
+ * Injected so one plugin instance has ONE table: the chip's read must see a routed review and a
224
+ * process cycle in the same place. Defaults to a private table, which is all a focused test needs.
225
+ */
226
+ activities?: VerifierActivities
212
227
  /** Durable cycle log of one topic. */
213
228
  store(agent: unknown): ProcessCycleStore
214
229
  /** Newest session state, used for staleness and for the comparison's task evidence. */
@@ -217,14 +232,14 @@ export interface ProcessSelectorDeps {
217
232
  current(intent: ProcessIntent): boolean
218
233
  /** Independent dispatch for the alternative reply (a fresh request object). */
219
234
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>
220
- compare(request: ProcessCompareRequest): Promise<CompareResult>
235
+ compare(request: ProcessCompareRequest): Promise<CompareResult & { decisionId?: string }>
221
236
  /**
222
237
  * Tournament over 3+ candidates; required only when `settings.candidates` is above 2.
223
238
  *
224
239
  * Optional on purpose: an embedder that never raises the count needs no tournament seam, and a
225
240
  * cycle that asks for one without it falls back to the pairwise path with a warning.
226
241
  */
227
- select?(request: ProcessSelectRequest): Promise<SelectResult>
242
+ select?(request: ProcessSelectRequest): Promise<SelectResult & { decisionId?: string }>
228
243
  record(report: ProcessCycleReport): Promise<void>
229
244
  /**
230
245
  * Rewrite the STATISTICS row of one already-recorded cycle because its delivery changed.
@@ -812,17 +827,22 @@ export class ProcessSelector {
812
827
  *
813
828
  * Host-only and in memory: a session event would carry the same information, but the persistence
814
829
  * read path refuses unknown event types for an out-of-repo plugin, and `Session.append` cannot
815
- * set the `ignorable` marker that would make one loadable (see `process-activity.ts`).
830
+ * set the `ignorable` marker that would make one loadable (see `verifier-activity.ts`).
816
831
  */
817
- private readonly activities = new ProcessActivities()
832
+ private readonly activities: VerifierActivities
818
833
 
819
- constructor(private readonly deps: ProcessSelectorDeps) {}
834
+ constructor(private readonly deps: ProcessSelectorDeps) {
835
+ this.activities = deps.activities ?? new VerifierActivities()
836
+ }
820
837
 
821
838
  /**
822
839
  * The cycle one session has to show right now, for the UI chip (in flight, or just settled).
840
+ *
841
+ * Reads the SAME table the router publishes into, so a routed review and a process cycle are never
842
+ * two different answers to "what is this session doing".
823
843
  * @param sessionId - the session to read.
824
844
  */
825
- activity(sessionId: string): ProcessActivityView { return this.activities.read(sessionId, this.deps.now()) }
845
+ activity(sessionId: string): ActivityView { return this.activities.read(sessionId, this.deps.now()) }
826
846
 
827
847
  /** Register (or replace) the pending intent of one session. */
828
848
  register(intent: ProcessIntent): void { this.intents.set(intent.sessionId, intent) }
@@ -966,6 +986,7 @@ export class ProcessSelector {
966
986
  // streaming text of its own, so the chip is the only thing that can explain it.
967
987
  this.activities.begin(intent.sessionId, {
968
988
  cycleId: reservation.id,
989
+ stage: 'process',
969
990
  phase: 'generating',
970
991
  candidates: count,
971
992
  ...(observation.alternativeModel === undefined ? {} : { alternativeModel: observation.alternativeModel }),
@@ -1230,8 +1251,8 @@ export class ProcessSelector {
1230
1251
  }
1231
1252
  // The generation and the buffering are behind us; everything this cycle still owes the user
1232
1253
  // is judge latency.
1233
- this.activities.phase(intent.sessionId, reservation.id, 'comparing')
1234
- let judged: { judgeCalls: number; winner: BufferedCandidate | undefined; tie: boolean; stats: RunStats; compare?: CompareResult; select?: SelectResult }
1254
+ this.activities.update(intent.sessionId, reservation.id, { phase: 'comparing' })
1255
+ let judged: { judgeCalls: number; winner: BufferedCandidate | undefined; tie: boolean; stats: RunStats; compare?: CompareResult; select?: SelectResult; decisionId?: string }
1235
1256
  try {
1236
1257
  if ('candidateA' in view) {
1237
1258
  const compared = await this.deps.compare({
@@ -1244,7 +1265,7 @@ export class ProcessSelector {
1244
1265
  repeats: PROCESS_REPEATS,
1245
1266
  signal: phase.signal,
1246
1267
  })
1247
- judged = { judgeCalls: compared.calls, winner: compared.winner === 'B' ? alternatives[0] : undefined, tie: compared.winner === 'tie', stats: compared.stats, compare: compared }
1268
+ judged = { judgeCalls: compared.calls, winner: compared.winner === 'B' ? alternatives[0] : undefined, tie: compared.winner === 'tie', stats: compared.stats, compare: compared, ...(compared.decisionId === undefined ? {} : { decisionId: compared.decisionId }) }
1248
1269
  } else {
1249
1270
  const selected = await this.deps.select!({
1250
1271
  agent: intent.agent,
@@ -1255,7 +1276,7 @@ export class ProcessSelector {
1255
1276
  repeats: PROCESS_REPEATS,
1256
1277
  signal: phase.signal,
1257
1278
  })
1258
- judged = { judgeCalls: selected.calls, winner: selected.index > 0 ? alternatives[selected.index - 1] : undefined, tie: false, stats: selected.stats, select: selected }
1279
+ judged = { judgeCalls: selected.calls, winner: selected.index > 0 ? alternatives[selected.index - 1] : undefined, tie: false, stats: selected.stats, select: selected, ...(selected.decisionId === undefined ? {} : { decisionId: selected.decisionId }) }
1259
1280
  }
1260
1281
  } catch (error) {
1261
1282
  // The judge run really happened before it failed: fold in every call the engine attached to
@@ -1306,7 +1327,7 @@ export class ProcessSelector {
1306
1327
  // dep. Both must state the delivery, and `finish` overwrites the cycle's own record.
1307
1328
  await this.deps.store(intent.agent).finish(reservation.id, outcome, 'original')
1308
1329
  // The chip follows the same correction: it was already settled as a replacement.
1309
- this.activities.finish(intent.sessionId, reservation.id, outcome, 'original', this.deps.now())
1330
+ this.activities.finish(intent.sessionId, reservation.id, classifyProcessOutcome(outcome, 'original'), this.deps.now())
1310
1331
  try {
1311
1332
  await this.deps.correctDelivery({ agent: intent.agent, cycleId: reservation.id, outcome, replayed: 'original' })
1312
1333
  } catch (error) {
@@ -1366,16 +1387,20 @@ export class ProcessSelector {
1366
1387
  }
1367
1388
 
1368
1389
  /** Record a purchased cycle and stamp its durable outcome. */
1369
- private async report(input: { intent: ProcessIntent; reservation: Reservation; observation: RouteObservation; startedAt: number; outcome: string; replayed: 'original' | 'candidate'; generatedCalls: number; judgeCalls: number; sameCandidate: boolean; usage: RunStats; compare?: CompareResult; select?: SelectResult; error?: string }): Promise<void> {
1390
+ private async report(input: { intent: ProcessIntent; reservation: Reservation; observation: RouteObservation; startedAt: number; outcome: string; replayed: 'original' | 'candidate'; generatedCalls: number; judgeCalls: number; sameCandidate: boolean; usage: RunStats; compare?: CompareResult & { decisionId?: string }; select?: SelectResult & { decisionId?: string }; error?: string }): Promise<void> {
1370
1391
  // The chip is told first: it is live UI state, and it must not depend on the cycle log or the
1371
1392
  // statistics row being writable (a read-only topic still gets an honest indicator).
1372
- this.activities.finish(input.intent.sessionId, input.reservation.id, input.outcome, input.replayed, this.deps.now())
1393
+ this.activities.finish(input.intent.sessionId, input.reservation.id, classifyProcessOutcome(input.outcome, input.replayed), this.deps.now())
1373
1394
  await this.deps.store(input.intent.agent).finish(input.reservation.id, input.outcome, input.replayed)
1395
+ // The judging seam filed the snapshot under an id of its own; the statistics row adopts it, so
1396
+ // "the snapshot of this row" resolves from the dashboard. Absent when nothing was captured.
1397
+ const decisionId = input.compare?.decisionId ?? input.select?.decisionId
1374
1398
  await this.deps.record({
1375
1399
  agent: input.intent.agent,
1376
1400
  cycleId: input.reservation.id,
1377
1401
  purchased: true,
1378
1402
  startedAt: input.startedAt,
1403
+ ...(decisionId === undefined ? {} : { decisionId }),
1379
1404
  outcome: input.outcome,
1380
1405
  replayed: input.replayed,
1381
1406
  generatedCalls: input.generatedCalls,
@@ -913,6 +913,52 @@ describe('structured route dedup and selection', () => {
913
913
  })
914
914
  })
915
915
 
916
+ describe('cycle observer for the chat chip', () => {
917
+ it('reports the grant, the promotion and the settlement of one cycle', () => {
918
+ const value = session(); const agent = { id: value.id, session: value }
919
+ const seen: string[] = []
920
+ const router = new AutoVerifierRouter({
921
+ begin: (_agent, reservation) => { seen.push('begin:' + reservation.phase + ':' + String(reservation.expectedCalls)) },
922
+ promoted: (_agent, reservation) => { seen.push('promoted:' + reservation.phase + ':' + String(reservation.expectedCalls)) },
923
+ settled: (_agent, reservation, outcome) => { seen.push('settled:' + reservation.phase + ':' + outcome) },
924
+ })
925
+ const classified = router.reserve(agent, 'semantic', 'classify', 1, policy)!
926
+ router.promote(agent, classified, 'compare', 'resolve', 5, policy)
927
+ expect(router.commit(agent, classified, 3)).toBe(true)
928
+ // The promotion is reported on the SAME reservation, which is what lets the chip move from
929
+ // "classifying" to "reviewing" instead of leaving a second cycle behind.
930
+ expect(seen).toEqual(['begin:semantic:1', 'promoted:compare:6', 'settled:compare:committed'])
931
+
932
+ const final = router.reserve(agent, 'final', 'final', 4, policy)!
933
+ router.fail(agent, final, false)
934
+ expect(seen.at(-1)).toBe('settled:final:failed')
935
+ })
936
+
937
+ it('reports nothing twice when a settlement is refused', () => {
938
+ const value = session(); const agent = { id: value.id, session: value }
939
+ let settled = 0
940
+ const router = new AutoVerifierRouter({ settled: () => { settled += 1 } })
941
+ const route = router.reserve(agent, 'compare', 'route', 4, policy)!
942
+ expect(router.commit(agent, route, 1)).toBe(true)
943
+ // A second commit for the same reservation is a no-op, and must not fake an edge.
944
+ expect(router.commit(agent, route, 1)).toBe(false)
945
+ expect(settled).toBe(1)
946
+ })
947
+
948
+ it('lets a broken observer throw without changing routing', () => {
949
+ // The observer is a reporting channel: a bug in the UI path must never cost a reservation, a
950
+ // verdict or the ability to route again.
951
+ const value = session(); const agent = { id: value.id, session: value }
952
+ const explode = () => { throw new Error('observer exploded') }
953
+ const router = new AutoVerifierRouter({ begin: explode, promoted: explode, settled: explode })
954
+ const classified = router.reserve(agent, 'semantic', 'classify', 1, policy)!
955
+ expect(router.promote(agent, classified, 'track', 'resolve', 2, policy)).toBe(true)
956
+ expect(router.fail(agent, classified, false)).toBeUndefined()
957
+ // The failed cycle released the in-flight slot, so routing continues.
958
+ expect(router.reserve(agent, 'compare', 'next', 4, policy)).toBeDefined()
959
+ })
960
+ })
961
+
916
962
  describe('transactional router state', () => {
917
963
  it('commits, requires final verification, and clears it only after final commit', () => {
918
964
  const value = session(); const agent = { id: value.id, session: value }; const router = new AutoVerifierRouter()
package/src/router.ts CHANGED
@@ -1412,6 +1412,22 @@ export function boundDecision(decision: RouteDecision | undefined, policy: Route
1412
1412
  return decision
1413
1413
  }
1414
1414
 
1415
+ /**
1416
+ * Advisory observer of granted routing cycles, used by the chat indicator.
1417
+ *
1418
+ * The router is the only component that knows a cycle was GRANTED and when it settled, but it must
1419
+ * not know what the UI says about it. Every callback is optional and exception-safe: an observer is
1420
+ * a reporting channel, and it must never be able to change routing, budgets or verdicts.
1421
+ */
1422
+ export interface RouterCycleObserver {
1423
+ /** A cycle was granted (the reservation now owns the agent's in-flight slot). */
1424
+ begin?(agent: RoutedAgent, reservation: Reservation): void
1425
+ /** A classification cycle was promoted into the decision it resolved, on the same reservation. */
1426
+ promoted?(agent: RoutedAgent, reservation: Reservation): void
1427
+ /** The cycle was committed (accepted) or failed (rejected / abandoned). */
1428
+ settled?(agent: RoutedAgent, reservation: Reservation, outcome: 'committed' | 'failed'): void
1429
+ }
1430
+
1415
1431
  export class AutoVerifierRouter {
1416
1432
  private readonly states = new Map<string, RouterState>()
1417
1433
  /** Agent ids that already received this task's budget-exhaustion notice. */
@@ -1420,6 +1436,17 @@ export class AutoVerifierRouter {
1420
1436
  /** Namespace for this router's cycle ids; unique per router and per process incarnation. */
1421
1437
  private readonly instance = ROUTER_EPOCH + '-' + (++routerInstanceSerial)
1422
1438
 
1439
+ /**
1440
+ * @param observer - optional reporting channel for the chat indicator.
1441
+ */
1442
+ constructor(private readonly observer?: RouterCycleObserver) {}
1443
+
1444
+ /** Report one lifecycle edge, swallowing anything the observer throws. */
1445
+ private observe(report: (observer: RouterCycleObserver) => void): void {
1446
+ if (this.observer === undefined) return
1447
+ try { report(this.observer) } catch { /* an advisory UI observer must never change routing */ }
1448
+ }
1449
+
1423
1450
  private state(agent: RoutedAgent): RouterState | undefined {
1424
1451
  const taskStartSeq = latestDirectUserSeq(sessionEvents(agent.session))
1425
1452
  if (taskStartSeq === undefined) return undefined
@@ -1472,6 +1499,7 @@ export class AutoVerifierRouter {
1472
1499
  }
1473
1500
  state.taskModelCalls += expectedCalls
1474
1501
  state.sessionModelCalls += expectedCalls
1502
+ this.observe(observer => observer.begin?.(agent, reservation))
1475
1503
  return reservation
1476
1504
  }
1477
1505
 
@@ -1519,6 +1547,7 @@ export class AutoVerifierRouter {
1519
1547
  reservation.expectedCalls += expectedCalls
1520
1548
  state.taskModelCalls += expectedCalls
1521
1549
  state.sessionModelCalls += expectedCalls
1550
+ this.observe(observer => observer.promoted?.(agent, reservation))
1522
1551
  return true
1523
1552
  }
1524
1553
 
@@ -1531,6 +1560,7 @@ export class AutoVerifierRouter {
1531
1560
  // (and, in strict mode, burn an attempt and set strictBlocked on that empty review).
1532
1561
  if (reservation.phase !== 'semantic' && reservation.phase !== 'final' && reservation.phase !== 'plan_review') state.finalRequiredFromSeq = Math.max(state.finalRequiredFromSeq ?? 0, evidenceSeq ?? reservation.taskStartSeq)
1533
1562
  if (reservation.phase === 'final') { state.finalRequiredFromSeq = undefined; state.finalPreferred = false }
1563
+ this.observe(observer => observer.settled?.(agent, reservation, 'committed'))
1534
1564
  return true
1535
1565
  }
1536
1566
 
@@ -1539,6 +1569,7 @@ export class AutoVerifierRouter {
1539
1569
  if (!state || state.inFlight?.id !== reservation.id) return
1540
1570
  state.inFlight = undefined; state.failed.add(reservation.fingerprint); if (strict) state.strictBlocked = true
1541
1571
  if (reservation.phase === 'final') state.finalPreferred = false
1572
+ this.observe(observer => observer.settled?.(agent, reservation, 'failed'))
1542
1573
  }
1543
1574
 
1544
1575
  /**
@@ -31,6 +31,22 @@ function stats(overrides: Partial<ReturnType<typeof emptyRunStats>> = {}) {
31
31
  }
32
32
 
33
33
  describe('StatisticsStore', () => {
34
+ it('stores a caller-supplied invocation id so the dashboard row can find its decision snapshot', async () => {
35
+ // The row and its snapshot must share ONE id: the "recall" button sends the id the row was
36
+ // listed under, and an independently generated uuid made that lookup miss every time.
37
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
38
+ const file = join(root, 'statistics.json')
39
+ const store = new StatisticsStore(file, 100)
40
+ const stored = await store.record({ id: 'invocation-9', toolName: 'verifier_current_session', startedAt: 1, success: true, provider: 'p', model: 'm', stats: stats() })
41
+ expect(stored.id).toBe('invocation-9')
42
+ const reopened = new StatisticsStore(file, 100)
43
+ const overview = await reopened.overview({ fromMs: 0, toMs: 10 })
44
+ expect(overview.recent[0]?.id).toBe('invocation-9')
45
+ // An unusable id falls back to a generated one instead of producing a nameless row.
46
+ const fallback = await store.record({ id: '', toolName: 'verifier_track', startedAt: 2, success: true, provider: 'p', model: 'm', stats: stats() })
47
+ expect(fallback.id).not.toBe('')
48
+ })
49
+
34
50
  it('records and aggregates invocations by day, tool, model, and session', async () => {
35
51
  const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
36
52
  const file = join(root, 'statistics.json')
package/src/statistics.ts CHANGED
@@ -336,6 +336,14 @@ export interface StatisticsQuery {
336
336
 
337
337
  export interface InvocationInput {
338
338
  toolName: VerifierToolName
339
+ /**
340
+ * Invocation id; a fresh uuid when omitted.
341
+ *
342
+ * Shared with the decision snapshot of the same invocation: the dashboard lists these rows and
343
+ * asks for "the snapshot of THIS row" by the id it was listed under, so the caller passes the
344
+ * id the snapshot was filed with (see `decisions.ts`).
345
+ */
346
+ id?: string
339
347
  sessionId?: string
340
348
  startedAt: number
341
349
  finishedAt?: number
@@ -581,7 +589,7 @@ export class StatisticsStore {
581
589
  const verdict = cleanVerdict(input.verdict)
582
590
  const route = cleanRoute(input.route)
583
591
  const record: InvocationRecord = {
584
- id: randomUUID(),
592
+ id: typeof input.id === 'string' && input.id.length > 0 ? input.id : randomUUID(),
585
593
  toolName: input.toolName,
586
594
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
587
595
  startedAt: input.startedAt,