dsh-auto-review 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 (84) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +226 -0
  4. package/README.hi.md +226 -0
  5. package/README.md +226 -0
  6. package/README.pt.md +226 -0
  7. package/README.zh.md +226 -0
  8. package/cordis.patch.yml +27 -0
  9. package/lib/client.js +494 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/events-CFl6CHZi.js +344 -0
  12. package/lib/index.js +5305 -0
  13. package/lib/invariant.js +223 -0
  14. package/lib/types/client/ReviewPanel.d.ts +29 -0
  15. package/lib/types/client/ReviewPanel.d.ts.map +1 -0
  16. package/lib/types/client/ReviewPanel.js +63 -0
  17. package/lib/types/client/ReviewPanel.js.map +1 -0
  18. package/lib/types/client/index.d.ts +31 -0
  19. package/lib/types/client/index.d.ts.map +1 -0
  20. package/lib/types/client/index.js +51 -0
  21. package/lib/types/client/index.js.map +1 -0
  22. package/lib/types/client/locales.d.ts +49 -0
  23. package/lib/types/client/locales.d.ts.map +1 -0
  24. package/lib/types/client/locales.js +82 -0
  25. package/lib/types/client/locales.js.map +1 -0
  26. package/lib/types/client/present.d.ts +25 -0
  27. package/lib/types/client/present.d.ts.map +1 -0
  28. package/lib/types/client/present.js +36 -0
  29. package/lib/types/client/present.js.map +1 -0
  30. package/lib/types/client/styles.d.ts +9 -0
  31. package/lib/types/client/styles.d.ts.map +1 -0
  32. package/lib/types/client/styles.js +130 -0
  33. package/lib/types/client/styles.js.map +1 -0
  34. package/lib/types/config.d.ts +200 -0
  35. package/lib/types/config.d.ts.map +1 -0
  36. package/lib/types/config.js +154 -0
  37. package/lib/types/config.js.map +1 -0
  38. package/lib/types/events.d.ts +347 -0
  39. package/lib/types/events.d.ts.map +1 -0
  40. package/lib/types/events.js +369 -0
  41. package/lib/types/events.js.map +1 -0
  42. package/lib/types/index.d.ts +26 -0
  43. package/lib/types/index.d.ts.map +1 -0
  44. package/lib/types/index.js +26 -0
  45. package/lib/types/index.js.map +1 -0
  46. package/lib/types/invariant.d.ts +28 -0
  47. package/lib/types/invariant.d.ts.map +1 -0
  48. package/lib/types/invariant.js +306 -0
  49. package/lib/types/invariant.js.map +1 -0
  50. package/lib/types/messages.d.ts +38 -0
  51. package/lib/types/messages.d.ts.map +1 -0
  52. package/lib/types/messages.js +65 -0
  53. package/lib/types/messages.js.map +1 -0
  54. package/lib/types/projection-types.d.ts +61 -0
  55. package/lib/types/projection-types.d.ts.map +1 -0
  56. package/lib/types/projection-types.js +8 -0
  57. package/lib/types/projection-types.js.map +1 -0
  58. package/lib/types/projection.d.ts +93 -0
  59. package/lib/types/projection.d.ts.map +1 -0
  60. package/lib/types/projection.js +197 -0
  61. package/lib/types/projection.js.map +1 -0
  62. package/lib/types/review.d.ts +141 -0
  63. package/lib/types/review.d.ts.map +1 -0
  64. package/lib/types/review.js +389 -0
  65. package/lib/types/review.js.map +1 -0
  66. package/lib/types/runtime.d.ts +160 -0
  67. package/lib/types/runtime.d.ts.map +1 -0
  68. package/lib/types/runtime.js +486 -0
  69. package/lib/types/runtime.js.map +1 -0
  70. package/package.json +132 -0
  71. package/src/client/ReviewPanel.tsx +164 -0
  72. package/src/client/index.ts +72 -0
  73. package/src/client/locales.ts +87 -0
  74. package/src/client/present.ts +58 -0
  75. package/src/client/styles.ts +131 -0
  76. package/src/config.ts +346 -0
  77. package/src/events.ts +553 -0
  78. package/src/index.ts +28 -0
  79. package/src/invariant.ts +326 -0
  80. package/src/messages.ts +91 -0
  81. package/src/projection-types.ts +61 -0
  82. package/src/projection.ts +224 -0
  83. package/src/review.ts +428 -0
  84. package/src/runtime.ts +562 -0
package/src/events.ts ADDED
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Durable session vocabulary for `dsh-auto-review`: the `autoReview/state`
3
+ * per-session override, the `autoReview/verdict` audit event, and the pure
4
+ * folds that read them back from a session log.
5
+ *
6
+ * The model-visible ⟺ logged invariant holds by construction: the verdict is
7
+ * log-only (UI-auditable, never in the model transcript), and the only
8
+ * model-visible auto-review content is the deny reason injected into the
9
+ * denied call's `tool/result`, which carries the `reviewId` marker that links
10
+ * it back to this event.
11
+ * @module dsh-auto-review/events
12
+ */
13
+
14
+ import type { Branded } from '@deepseek-ai/dsh-brand'
15
+ import type { CallId } from '@deepseek-ai/dsh-llm'
16
+ import type { SessionEvent, SessionEventMap, SessionId } from '@deepseek-ai/dsh-session'
17
+ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
18
+ import type { CircuitAction, RiskLevel } from './config.ts'
19
+
20
+ declare module '@deepseek-ai/dsh-session/types' {
21
+ interface SessionEventMap {
22
+ /**
23
+ * The session's auto-review switch — log-only, durable, replayable, last
24
+ * event wins (like `approval/policy`). The `/auto-review` command writes
25
+ * it; absent means the configured `enableByDefault`.
26
+ */
27
+ 'autoReview/state': {
28
+ enabled: boolean
29
+ }
30
+ /**
31
+ * One auto-review decision — log-only audit. `approvalId` links back to
32
+ * the `approval/asked` event and forward to the `approval/decided` pair,
33
+ * completing the reconstruction chain. `resolution` distinguishes a real
34
+ * reviewer verdict (`decision`/`reason`/`riskLevel` present, `fallback`
35
+ * absent) from a fallback (the converse). `outcome` is present only when
36
+ * the answerer claimed the request; a `delegate` fallback omits it
37
+ * because the chain continues downstream.
38
+ */
39
+ 'autoReview/verdict': {
40
+ reviewId: AutoReviewVerdictId
41
+ approvalId: ApprovalRequestId
42
+ toolName: string
43
+ callId?: CallId
44
+ provider: string
45
+ model?: string
46
+ reviewerSessionId?: SessionId
47
+ durationMs: number
48
+ decision?: 'allow' | 'deny'
49
+ reason?: string
50
+ riskLevel?: RiskLevel
51
+ outcome?: ApprovalOutcome
52
+ fallback?: AutoReviewFallback
53
+ error?: string
54
+ /**
55
+ * Present when the reviewer ALLOWED but the verdict did not settle the
56
+ * request: the risk policy overrode the allow (the request delegated to
57
+ * the human chain, or was deterministically denied per `onHighRisk`).
58
+ * A risk-policy override never carries `outcome: 'allowed-once'`.
59
+ */
60
+ escalation?: 'risk-policy'
61
+ }
62
+ /**
63
+ * The rejection circuit breaker tripped — log-only audit, once per turn.
64
+ * `circuitId` links the injected circuit-rejection marker back here.
65
+ */
66
+ 'autoReview/circuit': {
67
+ circuitId: AutoReviewCircuitId
68
+ action: CircuitAction
69
+ trip: { kind: 'consecutive' | 'window'; count: number }
70
+ toolName: string
71
+ }
72
+ /**
73
+ * A human one-shot override: the user explicitly authorized ONE retry of
74
+ * a previously denied action (the referenced deny verdict). Log-only;
75
+ * consumed by the next same-tool review within `overrideTtlMs`.
76
+ */
77
+ 'autoReview/override': {
78
+ reviewId: AutoReviewVerdictId
79
+ toolName: string
80
+ }
81
+ /**
82
+ * A deterministic hard-disable rejection (`never` policy): a risk rule or
83
+ * the tool policy table refused the request BEFORE any reviewer ran, and
84
+ * the answerer settled `rejected`. Log-only audit; `rejectionId` links the
85
+ * injected `[auto-review-never]` feedback text back here. `approvalId` is
86
+ * present whenever the pending `approval/asked` event could be correlated
87
+ * (like verdicts, at most one verdict-or-rejection exists per asked).
88
+ */
89
+ 'autoReview/rejection': {
90
+ rejectionId: AutoReviewRejectionId
91
+ approvalId?: ApprovalRequestId
92
+ toolName: string
93
+ callId?: CallId
94
+ /** Why the request was hard-disabled (the matched risk rule or policy table entry). */
95
+ reason: string
96
+ outcome: 'rejected'
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * `Session.append` narrowed to the `autoReview/state` event. The options bag
103
+ * exists only on host builds that expose the `ignorable` envelope-marker
104
+ * surface (post-rc.6 `@deepseek-ai/dsh-session`); an rc.6 host accepts and
105
+ * ignores the third argument, appending the identical event without the
106
+ * marker — no behavior change, no failure either way.
107
+ */
108
+ export type StateAppend = (
109
+ type: 'autoReview/state',
110
+ data: SessionEventMap['autoReview/state'],
111
+ options?: { ignorable?: true },
112
+ ) => unknown
113
+
114
+ /**
115
+ * `Session.append` narrowed to the `autoReview/verdict` event — same
116
+ * `ignorable`-marker contract as {@link StateAppend}.
117
+ */
118
+ export type VerdictAppend = (
119
+ type: 'autoReview/verdict',
120
+ data: SessionEventMap['autoReview/verdict'],
121
+ options?: { ignorable?: true },
122
+ ) => unknown
123
+
124
+ /** `Session.append` narrowed to the `autoReview/circuit` event — same contract as {@link VerdictAppend}. */
125
+ export type CircuitAppend = (
126
+ type: 'autoReview/circuit',
127
+ data: SessionEventMap['autoReview/circuit'],
128
+ options?: { ignorable?: true },
129
+ ) => unknown
130
+
131
+ /** `Session.append` narrowed to the `autoReview/override` event — same contract as {@link VerdictAppend}. */
132
+ export type OverrideAppend = (
133
+ type: 'autoReview/override',
134
+ data: SessionEventMap['autoReview/override'],
135
+ options?: { ignorable?: true },
136
+ ) => unknown
137
+
138
+ /** `Session.append` narrowed to the `autoReview/rejection` event — same contract as {@link VerdictAppend}. */
139
+ export type RejectionAppend = (
140
+ type: 'autoReview/rejection',
141
+ data: SessionEventMap['autoReview/rejection'],
142
+ options?: { ignorable?: true },
143
+ ) => unknown
144
+
145
+ /** Why the answerer had no reviewer verdict — the closed fallback vocabulary. */
146
+ export type AutoReviewFallback = 'timeout' | 'cancelled' | 'unavailable' | 'schema'
147
+
148
+ /** The reviewer child's durable session identity, as recorded in verdict events. */
149
+ export type ReviewerSessionId = SessionId
150
+
151
+ /** Closed {@link AutoReviewFallback} list for runtime normalization. */
152
+ export const AUTO_REVIEW_FALLBACKS: readonly AutoReviewFallback[] = ['timeout', 'cancelled', 'unavailable', 'schema']
153
+
154
+ /** Identifies one `autoReview/verdict` audit event. */
155
+ export type AutoReviewVerdictId = Branded<'AutoReviewVerdictId'>
156
+
157
+ /** Identifies one `autoReview/circuit` audit event. */
158
+ export type AutoReviewCircuitId = Branded<'AutoReviewCircuitId'>
159
+
160
+ /** Identifies one `autoReview/rejection` audit event. */
161
+ export type AutoReviewRejectionId = Branded<'AutoReviewRejectionId'>
162
+
163
+ /**
164
+ * Brand a string as an {@link AutoReviewVerdictId}.
165
+ * @param id - the raw id string.
166
+ * @returns the same string, branded (a compile-time cast — no runtime cost).
167
+ */
168
+ export function AutoReviewVerdictId(id: string): AutoReviewVerdictId {
169
+ return id as AutoReviewVerdictId
170
+ }
171
+
172
+ /**
173
+ * Brand a string as an {@link AutoReviewCircuitId}.
174
+ * @param id - the raw id string.
175
+ * @returns the same string, branded (a compile-time cast — no runtime cost).
176
+ */
177
+ export function AutoReviewCircuitId(id: string): AutoReviewCircuitId {
178
+ return id as AutoReviewCircuitId
179
+ }
180
+
181
+ /**
182
+ * Brand a string as an {@link AutoReviewRejectionId}.
183
+ * @param id - the raw id string.
184
+ * @returns the same string, branded (a compile-time cast — no runtime cost).
185
+ */
186
+ export function AutoReviewRejectionId(id: string): AutoReviewRejectionId {
187
+ return id as AutoReviewRejectionId
188
+ }
189
+
190
+ /** Stable marker prefix inside every injected deny reason, parsed by the invariant companion. */
191
+ export const DENY_MARKER = '[auto-review]'
192
+
193
+ /** Regex extracting the reviewId from an injected deny reason. */
194
+ export const DENY_MARKER_PATTERN = /\[auto-review\] review ([^\s]+) denied/u
195
+
196
+ /** Stable marker prefix inside every injected fallback rejection text (a distinct vocabulary from {@link DENY_MARKER}). */
197
+ export const FALLBACK_MARKER = '[auto-review-fallback]'
198
+
199
+ /** Regex extracting the reviewId from an injected fallback rejection. */
200
+ export const FALLBACK_MARKER_PATTERN = /\[auto-review-fallback\] review ([^\s]+) failed/u
201
+
202
+ /** Stable marker prefix inside every injected circuit rejection text. */
203
+ export const CIRCUIT_MARKER = '[auto-review-circuit]'
204
+
205
+ /** Regex extracting the circuitId and tool name from an injected circuit rejection. */
206
+ export const CIRCUIT_MARKER_PATTERN = /\[auto-review-circuit\] circuit ([^\s]+) rejected tool "([^"]+)" before review/u
207
+
208
+ /** Stable marker prefix inside every injected hard-disable (`never`) rejection text. */
209
+ export const NEVER_MARKER = '[auto-review-never]'
210
+
211
+ /** Regex extracting the rejectionId and tool name from an injected never rejection. */
212
+ export const NEVER_MARKER_PATTERN = /\[auto-review-never\] rejection ([^\s]+) hard-disabled tool "([^"]+)"/u
213
+
214
+ /**
215
+ * Build the model-visible deny text injected into a denied tool result. The
216
+ * embedded reviewId is what makes the model-visible text reconstructable
217
+ * from the session log (model-visible ⟺ logged).
218
+ * @param reviewId - the verdict event's id.
219
+ * @param toolName - the denied tool.
220
+ * @param reason - the reviewer's reason (already truncated).
221
+ * @returns the exact error text shown to the model.
222
+ */
223
+ export function denyResultText(reviewId: AutoReviewVerdictId, toolName: string, reason: string): string {
224
+ return `Error: ${DENY_MARKER} review ${reviewId} denied tool "${toolName}": ${reason}`
225
+ }
226
+
227
+ /**
228
+ * Build the model-visible text injected into a tool result whose review
229
+ * failed and was rejected by the fallback policy. The embedded reviewId
230
+ * links it to the recorded fallback verdict (model-visible ⟺ logged).
231
+ * @param reviewId - the fallback verdict event's id.
232
+ * @param fallback - the failure category (timeout/unavailable/schema).
233
+ * @param error - the failure detail (already truncated upstream).
234
+ * @returns the exact error text shown to the model.
235
+ */
236
+ export function fallbackResultText(reviewId: AutoReviewVerdictId, fallback: AutoReviewFallback, error: string): string {
237
+ return `Error: ${FALLBACK_MARKER} review ${reviewId} failed (${fallback}) and the request was rejected: ${error}`
238
+ }
239
+
240
+ /**
241
+ * Build the model-visible text injected into a tool result rejected by a
242
+ * tripped circuit breaker. The embedded circuitId links it to the recorded
243
+ * `autoReview/circuit` event (model-visible ⟺ logged).
244
+ * @param circuitId - the circuit event's id.
245
+ * @param toolName - the rejected tool.
246
+ * @param explanation - why the breaker rejects (trip kind and count).
247
+ * @returns the exact error text shown to the model.
248
+ */
249
+ export function circuitResultText(circuitId: AutoReviewCircuitId, toolName: string, explanation: string): string {
250
+ return `Error: ${CIRCUIT_MARKER} circuit ${circuitId} rejected tool "${toolName}" before review: ${explanation}`
251
+ }
252
+
253
+ /**
254
+ * Build the model-visible text injected into a tool result rejected by a
255
+ * `never` policy (a hard disable: risk rule or tool-policy table entry). The
256
+ * embedded rejectionId links it to the recorded `autoReview/rejection` event
257
+ * (model-visible ⟺ logged).
258
+ * @param rejectionId - the rejection event's id.
259
+ * @param toolName - the hard-disabled tool.
260
+ * @param source - which rule or policy entry disabled it.
261
+ * @returns the exact error text shown to the model.
262
+ */
263
+ export function neverResultText(rejectionId: AutoReviewRejectionId, toolName: string, source: string): string {
264
+ return `Error: ${NEVER_MARKER} rejection ${rejectionId} hard-disabled tool "${toolName}": ${source}`
265
+ }
266
+
267
+ /**
268
+ * The session's auto-review override: the last `autoReview/state` event, or
269
+ * `undefined` when the session never switched (callers apply the configured
270
+ * `enableByDefault`). The pure fold — replay IS the state.
271
+ * @param events - session events in log order (other types are skipped).
272
+ * @returns the enabled flag of the last switch, or undefined without one.
273
+ */
274
+ export function effectiveAutoReviewState(events: readonly SessionEvent[]): boolean | undefined {
275
+ for (let index = events.length - 1; index >= 0; index -= 1) {
276
+ const event = events[index] as SessionEvent
277
+ if (event.type === 'autoReview/state') return event.data.enabled
278
+ }
279
+ return undefined
280
+ }
281
+
282
+ /**
283
+ * The verdict events inside the session's CURRENT open turn, oldest first.
284
+ * A turn without `turn/start` (or one already closed by `turn/end`) yields
285
+ * an empty list.
286
+ * @param events - session events in log order.
287
+ * @returns the open turn's verdict payloads.
288
+ */
289
+ export function openTurnVerdicts(events: readonly SessionEvent[]): SessionEventMap['autoReview/verdict'][] {
290
+ let openSeq = -1
291
+ for (let index = events.length - 1; index >= 0; index -= 1) {
292
+ const type = (events[index] as SessionEvent).type
293
+ if (type === 'turn/end') return []
294
+ if (type === 'turn/start') {
295
+ openSeq = (events[index] as SessionEvent).seq
296
+ break
297
+ }
298
+ }
299
+ if (openSeq < 0) return []
300
+ const verdicts: SessionEventMap['autoReview/verdict'][] = []
301
+ for (let index = events.length - 1; index >= 0; index -= 1) {
302
+ const event = events[index] as SessionEvent
303
+ if (event.seq <= openSeq) break
304
+ if (event.type === 'autoReview/verdict') verdicts.push(event.data)
305
+ }
306
+ verdicts.reverse()
307
+ return verdicts
308
+ }
309
+
310
+ /**
311
+ * How many REAL AI verdicts the session's CURRENT open turn has consumed.
312
+ * Fallback verdicts do not count: they are budgeted separately by
313
+ * {@link autoReviewFailuresInOpenTurn}, so a failing reviewer does not eat
314
+ * the AI-decision budget. A turn without `turn/start` (or one already closed
315
+ * by `turn/end`) counts zero.
316
+ * @param events - session events in log order.
317
+ * @returns the decision-carrying verdict count inside the current open turn.
318
+ */
319
+ export function autoReviewsInOpenTurn(events: readonly SessionEvent[]): number {
320
+ return openTurnVerdicts(events).filter(data => data.decision !== undefined).length
321
+ }
322
+
323
+ /**
324
+ * How many reviewer FAILURES the session's CURRENT open turn has consumed —
325
+ * `autoReview/verdict` events carrying a fallback other than `cancelled`
326
+ * (a user withdrawal is not a reviewer failure). The answerer delegates once
327
+ * the budget is exhausted so a broken reviewer costs one timeout instead of
328
+ * `maxFailuresPerTurn` of them.
329
+ * @param events - session events in log order.
330
+ * @returns the failure count inside the current open turn.
331
+ */
332
+ export function autoReviewFailuresInOpenTurn(events: readonly SessionEvent[]): number {
333
+ return openTurnVerdicts(events).filter(data => data.fallback !== undefined && data.fallback !== 'cancelled').length
334
+ }
335
+
336
+ /**
337
+ * Whether a verdict is a denial of the request: a reviewer deny, or an allow
338
+ * overridden by the risk policy (`escalation: 'risk-policy'` with the deny
339
+ * outcome). The circuit breaker and the approve list count both.
340
+ * @param data - the verdict payload.
341
+ * @returns true when the request was denied.
342
+ */
343
+ export function isDenial(data: SessionEventMap['autoReview/verdict']): boolean {
344
+ return data.decision === 'deny' || data.escalation === 'risk-policy'
345
+ }
346
+
347
+ /**
348
+ * The trailing run of consecutive denials in the current open turn —
349
+ * the circuit breaker's `consecutiveDenies` signal.
350
+ * @param events - session events in log order.
351
+ * @returns the length of the trailing denial run.
352
+ */
353
+ export function consecutiveDeniesInOpenTurn(events: readonly SessionEvent[]): number {
354
+ const verdicts = openTurnVerdicts(events)
355
+ let count = 0
356
+ for (let index = verdicts.length - 1; index >= 0; index -= 1) {
357
+ if (!isDenial(verdicts[index]!)) break
358
+ count += 1
359
+ }
360
+ return count
361
+ }
362
+
363
+ /**
364
+ * How many of the last {@link windowSize} decision verdicts in the current
365
+ * open turn are denials — the circuit breaker's `windowDenies` signal.
366
+ * @param events - session events in log order.
367
+ * @param windowSize - how many recent verdicts the window counts.
368
+ * @returns the denial count inside the window.
369
+ */
370
+ export function deniesInRecentVerdicts(events: readonly SessionEvent[], windowSize: number): number {
371
+ const decisions = openTurnVerdicts(events).filter(data => data.decision !== undefined)
372
+ return decisions.slice(-windowSize).filter(data => isDenial(data)).length
373
+ }
374
+
375
+ /**
376
+ * The current open turn's circuit trip, when one was recorded.
377
+ * @param events - session events in log order.
378
+ * @returns the last `autoReview/circuit` payload in the open turn, or undefined.
379
+ */
380
+ export function circuitInOpenTurn(events: readonly SessionEvent[]): SessionEventMap['autoReview/circuit'] | undefined {
381
+ let openSeq = -1
382
+ for (let index = events.length - 1; index >= 0; index -= 1) {
383
+ const type = (events[index] as SessionEvent).type
384
+ if (type === 'turn/end') return undefined
385
+ if (type === 'turn/start') {
386
+ openSeq = (events[index] as SessionEvent).seq
387
+ break
388
+ }
389
+ }
390
+ if (openSeq < 0) return undefined
391
+ for (let index = events.length - 1; index >= 0; index -= 1) {
392
+ const event = events[index] as SessionEvent
393
+ if (event.seq <= openSeq) break
394
+ if (event.type === 'autoReview/circuit') return event.data
395
+ }
396
+ return undefined
397
+ }
398
+
399
+ /** One recent deny verdict, as the approve command lists them. */
400
+ export interface RecentDeny {
401
+ readonly reviewId: AutoReviewVerdictId
402
+ readonly toolName: string
403
+ readonly reason?: string
404
+ readonly riskLevel?: RiskLevel
405
+ }
406
+
407
+ /**
408
+ * The most recent deny verdicts, newest first — the `/auto-review approve`
409
+ * candidate list.
410
+ * @param events - session events in log order.
411
+ * @param limit - how many to collect.
412
+ * @returns up to {@link limit} deny verdicts, newest first.
413
+ */
414
+ export function lastDeniedVerdicts(events: readonly SessionEvent[], limit: number): RecentDeny[] {
415
+ const result: RecentDeny[] = []
416
+ for (let index = events.length - 1; index >= 0 && result.length < limit; index -= 1) {
417
+ const event = events[index] as SessionEvent
418
+ if (event.type !== 'autoReview/verdict' || !isDenial(event.data)) continue
419
+ const data = event.data
420
+ result.push({
421
+ reviewId: data.reviewId,
422
+ toolName: data.toolName,
423
+ ...data.reason !== undefined ? { reason: data.reason } : {},
424
+ ...data.riskLevel !== undefined ? { riskLevel: data.riskLevel } : {},
425
+ })
426
+ }
427
+ return result
428
+ }
429
+
430
+ /**
431
+ * Whether a human one-shot override is pending for a tool: the latest
432
+ * `autoReview/override` for the tool is still unconsumed (no verdict for the
433
+ * tool after it) and unexpired. An override is consumed by the NEXT
434
+ * same-tool review regardless of its decision — single-use by construction.
435
+ * @param events - session events in log order.
436
+ * @param toolName - the tool an approval request is about.
437
+ * @param ttlMs - the override lifetime.
438
+ * @param now - the current epoch milliseconds.
439
+ * @returns the overridden deny verdict's id, or undefined without a pending override.
440
+ */
441
+ export function activeOverride(
442
+ events: readonly SessionEvent[],
443
+ toolName: string,
444
+ ttlMs: number,
445
+ now: number,
446
+ ): AutoReviewVerdictId | undefined {
447
+ for (let index = events.length - 1; index >= 0; index -= 1) {
448
+ const event = events[index] as SessionEvent
449
+ if (event.type === 'autoReview/verdict' && event.data.toolName === toolName) return undefined
450
+ if (event.type === 'autoReview/override' && event.data.toolName === toolName) {
451
+ return now - event.time <= ttlMs ? event.data.reviewId : undefined
452
+ }
453
+ }
454
+ return undefined
455
+ }
456
+
457
+ /** Cumulative review statistics folded from the session log (status command). */
458
+ export interface ReviewStats {
459
+ readonly allows: number
460
+ readonly denies: number
461
+ readonly fallbacks: number
462
+ /** Hard-disable (`never` policy) rejections — no reviewer ran for these. */
463
+ readonly rejections: number
464
+ /** Mean duration of decision-carrying verdicts, rounded; 0 without any. */
465
+ readonly avgDurationMs: number
466
+ /** The last three verdicts, newest first. */
467
+ readonly recent: readonly SessionEventMap['autoReview/verdict'][]
468
+ }
469
+
470
+ /**
471
+ * Fold one session's review statistics: verdict counts, never-rejection
472
+ * count, mean duration, and the three most recent verdicts.
473
+ * @param events - session events in log order.
474
+ * @returns the statistics.
475
+ */
476
+ export function reviewStats(events: readonly SessionEvent[]): ReviewStats {
477
+ let allows = 0
478
+ let denies = 0
479
+ let fallbacks = 0
480
+ let rejections = 0
481
+ let decided = 0
482
+ let durationSum = 0
483
+ for (const event of events) {
484
+ if (event.type === 'autoReview/rejection') {
485
+ rejections += 1
486
+ continue
487
+ }
488
+ if (event.type !== 'autoReview/verdict') continue
489
+ const data = event.data
490
+ if (data.decision === 'allow') allows += 1
491
+ else if (data.decision === 'deny') denies += 1
492
+ else fallbacks += 1
493
+ if (data.decision !== undefined) {
494
+ decided += 1
495
+ durationSum += data.durationMs
496
+ }
497
+ }
498
+ const recent: SessionEventMap['autoReview/verdict'][] = []
499
+ for (let index = events.length - 1; index >= 0 && recent.length < 3; index -= 1) {
500
+ const event = events[index] as SessionEvent
501
+ if (event.type === 'autoReview/verdict') recent.push(event.data)
502
+ }
503
+ return { allows, denies, fallbacks, rejections, avgDurationMs: decided === 0 ? 0 : Math.round(durationSum / decided), recent }
504
+ }
505
+
506
+ /**
507
+ * Correlate a pending approval request with the `approval/asked` event the
508
+ * service just appended. The service appends the asked event synchronously
509
+ * before waterfall dispatch, so the matching event is the last unpaired
510
+ * asked event whose call id (or, without one, tool name) matches the request.
511
+ * @param events - session events in log order.
512
+ * @param toolName - the request's tool name.
513
+ * @param callId - the request's call id, when present.
514
+ * @returns the asked event's approval id, or undefined when the chain is broken.
515
+ */
516
+ export function correlateApprovalId(
517
+ events: readonly SessionEvent[],
518
+ toolName: string,
519
+ callId?: CallId,
520
+ ): ApprovalRequestId | undefined {
521
+ const decided = new Set<ApprovalRequestId>()
522
+ let candidate: ApprovalRequestId | undefined
523
+ for (let index = events.length - 1; index >= 0; index -= 1) {
524
+ const event = events[index] as SessionEvent
525
+ if (event.type === 'approval/decided') {
526
+ decided.add(event.data.id)
527
+ continue
528
+ }
529
+ if (event.type !== 'approval/asked') continue
530
+ const asked = event.data
531
+ if (decided.has(asked.id)) continue
532
+ if (callId !== undefined ? asked.callId === callId : asked.toolName === toolName) {
533
+ candidate = asked.id
534
+ break
535
+ }
536
+ }
537
+ return candidate
538
+ }
539
+
540
+ /**
541
+ * Find the already-presented tool call the approval request refers to — the
542
+ * `tool/call` event whose arguments the model produced and the UI streamed.
543
+ * @param events - session events in log order.
544
+ * @param callId - the exact call to find.
545
+ * @returns the call's raw arguments JSON string, or undefined without one.
546
+ */
547
+ export function findPresentedCall(events: readonly SessionEvent[], callId: CallId): string | undefined {
548
+ for (let index = events.length - 1; index >= 0; index -= 1) {
549
+ const event = events[index] as SessionEvent
550
+ if (event.type === 'tool/call' && event.data.callId === callId) return event.data.arguments
551
+ }
552
+ return undefined
553
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `dsh-auto-review` — second-model AI auto-review for DeepSeek Harness
3
+ * approval requests. Registers an answerer on the `approval/request`
4
+ * waterfall that, when a session has auto-review enabled and the configured
5
+ * tool/risk policy says `ai`, runs a read-only one-shot reviewer subagent
6
+ * (`toolFilter` allow-list, structured verdict schema) and settles the
7
+ * request from its verdict; every other request delegates via `next()` to
8
+ * the human answerer chain. Failures fall back per `fallbackPolicy` (default
9
+ * fail-closed `rejected`).
10
+ *
11
+ * Function plugin — no default export (the Loader unwraps
12
+ * `exports.default ?? exports`, and a stray default would discard
13
+ * `name`/`inject`/`Config`/`apply`).
14
+ * @module dsh-auto-review
15
+ */
16
+
17
+ import { apply } from './runtime.ts'
18
+
19
+ export const name = 'auto-review'
20
+ export const inject = ['approval', 'subagents', 'commands', 'tools']
21
+
22
+ export { apply }
23
+ export { AutoReviewRuntime } from './runtime.ts'
24
+ export * from './config.ts'
25
+ export * from './events.ts'
26
+ export * from './review.ts'
27
+ export * from './projection.ts'
28
+ export * from './projection-types.ts'