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.
- package/CHANGELOG.md +91 -0
- package/LICENSE +201 -0
- package/README.es.md +226 -0
- package/README.hi.md +226 -0
- package/README.md +226 -0
- package/README.pt.md +226 -0
- package/README.zh.md +226 -0
- package/cordis.patch.yml +27 -0
- package/lib/client.js +494 -0
- package/lib/client.js.map +1 -0
- package/lib/events-CFl6CHZi.js +344 -0
- package/lib/index.js +5305 -0
- package/lib/invariant.js +223 -0
- package/lib/types/client/ReviewPanel.d.ts +29 -0
- package/lib/types/client/ReviewPanel.d.ts.map +1 -0
- package/lib/types/client/ReviewPanel.js +63 -0
- package/lib/types/client/ReviewPanel.js.map +1 -0
- package/lib/types/client/index.d.ts +31 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +51 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts +49 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +82 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/present.d.ts +25 -0
- package/lib/types/client/present.d.ts.map +1 -0
- package/lib/types/client/present.js +36 -0
- package/lib/types/client/present.js.map +1 -0
- package/lib/types/client/styles.d.ts +9 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +130 -0
- package/lib/types/client/styles.js.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +154 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/events.d.ts +347 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +369 -0
- package/lib/types/events.js.map +1 -0
- package/lib/types/index.d.ts +26 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +26 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts +28 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +306 -0
- package/lib/types/invariant.js.map +1 -0
- package/lib/types/messages.d.ts +38 -0
- package/lib/types/messages.d.ts.map +1 -0
- package/lib/types/messages.js +65 -0
- package/lib/types/messages.js.map +1 -0
- package/lib/types/projection-types.d.ts +61 -0
- package/lib/types/projection-types.d.ts.map +1 -0
- package/lib/types/projection-types.js +8 -0
- package/lib/types/projection-types.js.map +1 -0
- package/lib/types/projection.d.ts +93 -0
- package/lib/types/projection.d.ts.map +1 -0
- package/lib/types/projection.js +197 -0
- package/lib/types/projection.js.map +1 -0
- package/lib/types/review.d.ts +141 -0
- package/lib/types/review.d.ts.map +1 -0
- package/lib/types/review.js +389 -0
- package/lib/types/review.js.map +1 -0
- package/lib/types/runtime.d.ts +160 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +486 -0
- package/lib/types/runtime.js.map +1 -0
- package/package.json +132 -0
- package/src/client/ReviewPanel.tsx +164 -0
- package/src/client/index.ts +72 -0
- package/src/client/locales.ts +87 -0
- package/src/client/present.ts +58 -0
- package/src/client/styles.ts +131 -0
- package/src/config.ts +346 -0
- package/src/events.ts +553 -0
- package/src/index.ts +28 -0
- package/src/invariant.ts +326 -0
- package/src/messages.ts +91 -0
- package/src/projection-types.ts +61 -0
- package/src/projection.ts +224 -0
- package/src/review.ts +428 -0
- 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'
|