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
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Host projection unit for the `autoReview` panel value: folds the log-only
3
+ * `autoReview/*` events into one wire-JSON whole value the browser review
4
+ * panel reads via `useProjection('autoReview')`. Pure mathematics only — the
5
+ * framework owns subscriptions, watermarks, change feeds, and persistence.
6
+ * The fold mirrors the `events.ts` pure folds (budgets, statistics, denials).
7
+ * @module dsh-auto-review/projection
8
+ */
9
+
10
+ import { z } from 'zod'
11
+ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
12
+ import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
13
+ import type { AutoReviewDenyView, AutoReviewProjection, AutoReviewVerdictView } from './projection-types.ts'
14
+
15
+ /** How many verdict rows the panel carries. */
16
+ const RECENT_CAP = 8
17
+ /** How many approve candidates the panel offers. */
18
+ const DENIES_CAP = 5
19
+
20
+ const VERDICT_VIEW_SCHEMA = z.object({
21
+ reviewId: z.string(),
22
+ toolName: z.string(),
23
+ at: z.number(),
24
+ durationMs: z.number().int().nonnegative(),
25
+ decision: z.union([z.literal('allow'), z.literal('deny')]).optional(),
26
+ riskLevel: z.union([z.literal('low'), z.literal('medium'), z.literal('high')]).optional(),
27
+ escalation: z.literal('risk-policy').optional(),
28
+ fallback: z.string().optional(),
29
+ outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]).optional(),
30
+ reason: z.string().optional(),
31
+ })
32
+
33
+ const DENY_VIEW_SCHEMA = z.object({
34
+ reviewId: z.string(),
35
+ toolName: z.string(),
36
+ reason: z.string().optional(),
37
+ })
38
+
39
+ const CIRCUIT_VIEW_SCHEMA = z.object({
40
+ action: z.union([z.literal('delegate'), z.literal('reject'), z.literal('abort-turn')]),
41
+ trip: z.object({
42
+ kind: z.union([z.literal('consecutive'), z.literal('window')]),
43
+ count: z.number().int().positive(),
44
+ }),
45
+ toolName: z.string(),
46
+ }).nullable()
47
+
48
+ /** Strict wire schema — validates the `view` output before it leaves the host. */
49
+ export const AUTO_REVIEW_PROJECTION_SCHEMA = z.object({
50
+ enabled: z.boolean(),
51
+ verdictsUsed: z.number().int().nonnegative(),
52
+ failuresUsed: z.number().int().nonnegative(),
53
+ allows: z.number().int().nonnegative(),
54
+ denies: z.number().int().nonnegative(),
55
+ fallbacks: z.number().int().nonnegative(),
56
+ neverRejects: z.number().int().nonnegative(),
57
+ avgDurationMs: z.number().int().nonnegative(),
58
+ circuit: CIRCUIT_VIEW_SCHEMA,
59
+ recent: z.array(VERDICT_VIEW_SCHEMA),
60
+ recentDenies: z.array(DENY_VIEW_SCHEMA),
61
+ })
62
+
63
+ /** The fold's plain-JSON state (the persisted-cache precondition). */
64
+ export interface AutoReviewProjectionState {
65
+ readonly enabled: boolean
66
+ readonly turnOpen: boolean
67
+ readonly verdictsUsed: number
68
+ readonly failuresUsed: number
69
+ readonly allows: number
70
+ readonly denies: number
71
+ readonly fallbacks: number
72
+ readonly neverRejects: number
73
+ readonly durationSum: number
74
+ readonly decided: number
75
+ readonly circuit: AutoReviewProjection['circuit']
76
+ readonly recent: readonly AutoReviewVerdictView[]
77
+ readonly recentDenies: readonly AutoReviewDenyView[]
78
+ }
79
+
80
+ /** Drop `readonly` from a view type so a fold can fill optional fields conditionally. */
81
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] }
82
+
83
+ /**
84
+ * State for the empty log. `enabled` starts at the resolved
85
+ * `enableByDefault` — the projection cannot know the plugin config on its
86
+ * own, so the unit is built per mount by {@link makeAutoReviewProjection}.
87
+ * @param enabledByDefault - the resolved `enableByDefault` config value.
88
+ */
89
+ export function initAutoReviewProjection(enabledByDefault: boolean): AutoReviewProjectionState {
90
+ return {
91
+ enabled: enabledByDefault,
92
+ turnOpen: false,
93
+ verdictsUsed: 0,
94
+ failuresUsed: 0,
95
+ allows: 0,
96
+ denies: 0,
97
+ fallbacks: 0,
98
+ neverRejects: 0,
99
+ durationSum: 0,
100
+ decided: 0,
101
+ circuit: null,
102
+ recent: [],
103
+ recentDenies: [],
104
+ }
105
+ }
106
+
107
+ /** One verdict event as its panel row. */
108
+ function verdictView(data: SessionEventMap['autoReview/verdict'], at: number): AutoReviewVerdictView {
109
+ const view: Mutable<AutoReviewVerdictView> = {
110
+ reviewId: String(data.reviewId),
111
+ toolName: data.toolName,
112
+ at,
113
+ durationMs: data.durationMs,
114
+ }
115
+ if (data.decision !== undefined) view.decision = data.decision
116
+ if (data.riskLevel !== undefined) view.riskLevel = data.riskLevel
117
+ if (data.escalation !== undefined) view.escalation = data.escalation
118
+ if (data.fallback !== undefined) view.fallback = data.fallback
119
+ if (data.outcome !== undefined) view.outcome = data.outcome
120
+ if (data.reason !== undefined) view.reason = data.reason
121
+ return view
122
+ }
123
+
124
+ /** One denial event as its approve row. */
125
+ function denyView(data: SessionEventMap['autoReview/verdict']): AutoReviewDenyView {
126
+ const view: Mutable<AutoReviewDenyView> = {
127
+ reviewId: String(data.reviewId),
128
+ toolName: data.toolName,
129
+ }
130
+ if (data.reason !== undefined) view.reason = data.reason
131
+ return view
132
+ }
133
+
134
+ /**
135
+ * Pure transition: previous state + one committed event → next state.
136
+ * Uninterested events return the same state reference.
137
+ * @param state - the state covering all prior events.
138
+ * @param event - the next committed session event.
139
+ * @returns the next state (same reference when the event is not this unit's).
140
+ */
141
+ export function applyAutoReview(state: AutoReviewProjectionState, event: SessionEvent): AutoReviewProjectionState {
142
+ switch (event.type) {
143
+ case 'turn/start':
144
+ return state.turnOpen
145
+ ? state
146
+ : { ...state, turnOpen: true, verdictsUsed: 0, failuresUsed: 0, circuit: null }
147
+ case 'turn/end':
148
+ return state.turnOpen ? { ...state, turnOpen: false } : state
149
+ case 'autoReview/state':
150
+ return state.enabled === event.data.enabled ? state : { ...state, enabled: event.data.enabled }
151
+ case 'autoReview/circuit':
152
+ return {
153
+ ...state,
154
+ circuit: { action: event.data.action, trip: event.data.trip, toolName: event.data.toolName },
155
+ }
156
+ case 'autoReview/rejection':
157
+ return { ...state, neverRejects: state.neverRejects + 1 }
158
+ case 'autoReview/verdict': {
159
+ const data = event.data
160
+ const decision = data.decision !== undefined
161
+ const failure = data.fallback !== undefined && data.fallback !== 'cancelled'
162
+ const denial = data.decision === 'deny' || data.escalation === 'risk-policy'
163
+ const row = verdictView(data, event.time)
164
+ return {
165
+ ...state,
166
+ verdictsUsed: state.turnOpen && decision ? state.verdictsUsed + 1 : state.verdictsUsed,
167
+ failuresUsed: state.turnOpen && failure ? state.failuresUsed + 1 : state.failuresUsed,
168
+ allows: state.allows + (data.decision === 'allow' ? 1 : 0),
169
+ denies: state.denies + (data.decision === 'deny' ? 1 : 0),
170
+ fallbacks: state.fallbacks + (decision ? 0 : 1),
171
+ durationSum: state.durationSum + (decision ? data.durationMs : 0),
172
+ decided: state.decided + (decision ? 1 : 0),
173
+ recent: [row, ...state.recent].slice(0, RECENT_CAP),
174
+ recentDenies: denial
175
+ ? [denyView(data), ...state.recentDenies].slice(0, DENIES_CAP)
176
+ : state.recentDenies,
177
+ }
178
+ }
179
+ default:
180
+ return state
181
+ }
182
+ }
183
+
184
+ /** State → wire payload (the read-side projection). */
185
+ export function viewAutoReview(state: AutoReviewProjectionState): AutoReviewProjection {
186
+ return {
187
+ enabled: state.enabled,
188
+ verdictsUsed: state.verdictsUsed,
189
+ failuresUsed: state.failuresUsed,
190
+ allows: state.allows,
191
+ denies: state.denies,
192
+ fallbacks: state.fallbacks,
193
+ neverRejects: state.neverRejects,
194
+ avgDurationMs: state.decided === 0 ? 0 : Math.round(state.durationSum / state.decided),
195
+ circuit: state.circuit,
196
+ recent: state.recent,
197
+ recentDenies: state.recentDenies,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Build the registered unit for one mount. The unit's initial `enabled` is
203
+ * the mount's resolved `enableByDefault` (the pure `init` cannot see plugin
204
+ * config otherwise), and the panel therefore reports the same switch state
205
+ * the answerer applies. Registrants sharing the `autoReview` key share one
206
+ * unit; the first mount's default wins, which only matters for a double
207
+ * mount with conflicting `enableByDefault` values.
208
+ * @param enabledByDefault - the resolved `enableByDefault` config value.
209
+ * @returns the projection unit for `ctx.sessionProjections.register`.
210
+ */
211
+ export function makeAutoReviewProjection(enabledByDefault: boolean): ProjectionDefinition<'autoReview', AutoReviewProjectionState> {
212
+ return {
213
+ key: 'autoReview',
214
+ // zod v4's `.optional()` output carries `| undefined`, which exact-optional
215
+ // property types reject; the runtime validation is what matters on the wire.
216
+ schema: AUTO_REVIEW_PROJECTION_SCHEMA as unknown as ProjectionDefinition<'autoReview', AutoReviewProjectionState>['schema'],
217
+ init: () => initAutoReviewProjection(enabledByDefault),
218
+ apply: applyAutoReview,
219
+ view: viewAutoReview,
220
+ // v2: `init.enabled` follows `enableByDefault` (was hardcoded true) and the
221
+ // wire gained the cumulative `neverRejects` counter.
222
+ stateVersion: 2,
223
+ }
224
+ }
package/src/review.ts ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The reviewer subagent: one-shot in-process child with a read-only tool
3
+ * allow-list and a structured verdict schema, raced against the configured
4
+ * timeout and the request's cancellation signal. Any failure resolves to a
5
+ * {@link ReviewFailure} — the caller applies the fallback policy, never a
6
+ * grant (fail closed by default).
7
+ * @module dsh-auto-review/review
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { CallId, ContentBlock } from '@deepseek-ai/dsh-llm'
13
+ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
14
+ import type { ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
15
+ import type {} from '@deepseek-ai/dsh-subagent'
16
+ import { assertObjectJsonSchema, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
17
+ import type { AutoReviewFallback } from './events.ts'
18
+ import { AutoReviewCircuitId, AutoReviewRejectionId, AutoReviewVerdictId, findPresentedCall } from './events.ts'
19
+ import type { ContextBudgetConfig, ResolvedConfig, RiskLevel } from './config.ts'
20
+
21
+ /** A reviewer verdict, validated against the closed decision vocabulary. */
22
+ export interface ReviewerVerdict {
23
+ readonly decision: 'allow' | 'deny'
24
+ readonly reason: string
25
+ readonly riskLevel?: RiskLevel
26
+ /** The reviewer child's session id (its own log is auditable). */
27
+ readonly reviewerSessionId?: SessionId
28
+ /** The explicitly configured reviewer model, when one was set. */
29
+ readonly model?: string
30
+ }
31
+
32
+ /**
33
+ * A review that produced no verdict. The caller maps the failure to the
34
+ * configured fallback policy.
35
+ */
36
+ export interface ReviewFailure {
37
+ readonly fallback: AutoReviewFallback
38
+ readonly error: string
39
+ readonly reviewerSessionId?: SessionId
40
+ readonly model?: string
41
+ }
42
+
43
+ /** The closed result of one review attempt. */
44
+ export type ReviewResolution = ReviewerVerdict | ReviewFailure
45
+
46
+ /** Distinguish a verdict resolution from a failure. */
47
+ export function isReviewFailure(resolution: ReviewResolution): resolution is ReviewFailure {
48
+ return (resolution as ReviewFailure).fallback !== undefined
49
+ }
50
+
51
+ /** Object-rooted verdict schema enforced on the child's structured_output capture. */
52
+ export const VERDICT_SCHEMA: ObjectJsonSchema = {
53
+ type: 'object',
54
+ properties: {
55
+ decision: { type: 'string', enum: ['allow', 'deny'] },
56
+ reason: { type: 'string' },
57
+ riskLevel: { type: 'string', enum: ['low', 'medium', 'high'] },
58
+ },
59
+ required: ['decision', 'reason'],
60
+ }
61
+
62
+ // Prove at load time that the verdict schema obeys the enforced JSON-schema
63
+ // subset; a regression here must fail the plugin loudly, not every review.
64
+ assertObjectJsonSchema(VERDICT_SCHEMA)
65
+
66
+ /** Keys whose values are redacted from the reviewer prompt. */
67
+ const SENSITIVE_KEY_PATTERN = /(?:api[_-]?key|token|secret|password|passwd|pwd|authorization|auth|credential|private[_-]?key|access[_-]?key)/iu
68
+
69
+ /** The redaction placeholder for sensitive argument values. */
70
+ const REDACTED = '[REDACTED]'
71
+
72
+ /**
73
+ * Recursively redact sensitive values from a JSON value: any object key
74
+ * matching {@link SENSITIVE_KEY_PATTERN} has its value replaced.
75
+ * @param value - the detached JSON value to sanitize.
76
+ * @returns a detached copy with sensitive values redacted.
77
+ */
78
+ export function sanitizeArguments(value: unknown): unknown {
79
+ if (Array.isArray(value)) return value.map(item => sanitizeArguments(item))
80
+ if (typeof value === 'object' && value !== null) {
81
+ const copy: Record<string, unknown> = {}
82
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
83
+ copy[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : sanitizeArguments(item)
84
+ }
85
+ return copy
86
+ }
87
+ return value
88
+ }
89
+
90
+ /**
91
+ * The already-presented call arguments, parsed and key-redacted, as pretty
92
+ * JSON — the raw material for both the reviewer prompt section and the
93
+ * `arguments` risk-rule field.
94
+ * @param events - the requesting session's log.
95
+ * @param callId - the call to render.
96
+ * @returns the sanitized JSON text, or undefined when the log lacks a parseable call.
97
+ */
98
+ export function sanitizedArgumentsText(events: readonly SessionEvent[], callId: CallId): string | undefined {
99
+ const raw = findPresentedCall(events, callId)
100
+ if (raw === undefined) return undefined
101
+ let parsed: unknown
102
+ try {
103
+ parsed = JSON.parse(raw) as unknown
104
+ } catch {
105
+ return undefined
106
+ }
107
+ try {
108
+ return JSON.stringify(sanitizeArguments(parsed), undefined, 2)
109
+ } catch {
110
+ return String(parsed)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Render the tool-call context for the reviewer: the already-streamed call
116
+ * arguments (parsed, redacted, truncated) or a note when the log lacks them.
117
+ * @param events - the requesting session's log.
118
+ * @param callId - the call to render.
119
+ * @param maxChars - the preview cap (shared with reason truncation).
120
+ * @returns the prompt section text.
121
+ */
122
+ export function renderCallContext(events: readonly SessionEvent[], callId: CallId | undefined, maxChars: number): string {
123
+ if (callId === undefined) return 'Tool call arguments: (not available — the asker attached no call id)'
124
+ const text = sanitizedArgumentsText(events, callId)
125
+ if (text === undefined) return 'Tool call arguments: (not found or unparseable in the presented transcript)'
126
+ return `Tool call arguments (sensitive values redacted):\n${truncate(text, maxChars)}`
127
+ }
128
+
129
+ /** How many characters one transcript line may take before truncation. */
130
+ const CONTEXT_LINE_BUDGET = 300
131
+
132
+ /** Extract the text blocks of a message's content as one string. */
133
+ function contentText(content: readonly ContentBlock[]): string {
134
+ return content
135
+ .filter(block => block.type === 'text')
136
+ .map(block => (block as { text: string }).text)
137
+ .join('\n')
138
+ }
139
+
140
+ /** One transcript event rendered as a compact line, or undefined when the event carries no usable text. */
141
+ function contextLine(event: SessionEvent): string | undefined {
142
+ switch (event.type) {
143
+ case 'user/message': return `[user] ${truncate(contentText(event.data.content), CONTEXT_LINE_BUDGET)}`
144
+ case 'assistant/message': return `[agent] ${truncate(contentText(event.data.message.content), CONTEXT_LINE_BUDGET)}`
145
+ case 'tool/call': {
146
+ const args = event.data.arguments
147
+ return `[tool call ${event.data.name}] ${truncate(args, CONTEXT_LINE_BUDGET)}`
148
+ }
149
+ case 'tool/result': {
150
+ const block = event.data.message.content[0]
151
+ if (block === undefined || block.type !== 'tool-result') return undefined
152
+ const text = contentText(block.content)
153
+ return text === '' ? undefined : `[tool result] ${truncate(text, CONTEXT_LINE_BUDGET)}`
154
+ }
155
+ default: return undefined
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Build the compact-transcript section for the reviewer: the tail of the
161
+ * session's user/assistant messages and tool calls/results, most recent
162
+ * last, bounded by {@link ContextBudgetConfig}. `turns: 0` yields an empty
163
+ * section. Tool-call arguments and tool-result text enter verbatim (they are
164
+ * already-presented transcript content); the presented call of the pending
165
+ * request is redacted separately in {@link renderCallContext}.
166
+ * @param events - the requesting session's log.
167
+ * @param budget - how much context to include.
168
+ * @returns the prompt section text (empty when disabled).
169
+ */
170
+ export function buildContextSection(events: readonly SessionEvent[], budget: ContextBudgetConfig): string {
171
+ if (budget.turns === 0) return ''
172
+ const lines: string[] = []
173
+ let crossed = 0
174
+ for (let index = events.length - 1; index >= 0; index -= 1) {
175
+ const event = events[index] as SessionEvent
176
+ if (event.type === 'turn/end') {
177
+ crossed += 1
178
+ if (crossed >= budget.turns) break
179
+ continue
180
+ }
181
+ if (event.type === 'turn/start') continue
182
+ const line = contextLine(event)
183
+ if (line !== undefined) lines.push(line)
184
+ }
185
+ lines.reverse()
186
+ const joined = lines.join('\n')
187
+ if (joined === '') return '(no transcript content yet)'
188
+ return truncate(joined, budget.maxChars)
189
+ }
190
+
191
+ /** Context of a pending human override, passed into the reviewer prompt. */
192
+ export interface OverrideContext {
193
+ readonly reviewId: AutoReviewVerdictId
194
+ readonly toolName: string
195
+ }
196
+
197
+ /**
198
+ * Truncate a string to a character budget without cutting surrogate pairs.
199
+ * @param text - the string to cap.
200
+ * @param maxChars - the positive budget.
201
+ * @returns the capped string.
202
+ */
203
+ export function truncate(text: string, maxChars: number): string {
204
+ if (text.length <= maxChars) return text
205
+ const cut = text.slice(0, maxChars)
206
+ const last = cut.charCodeAt(cut.length - 1)
207
+ if (last >= 0xd800 && last <= 0xdbff) return cut.slice(0, -1)
208
+ return `${cut}…`
209
+ }
210
+
211
+ /**
212
+ * Build the reviewer prompt: role, the pending request's tool/reason/context/
213
+ * workspace, the resolved risk rules, the ruling policy text, the optional
214
+ * human-override context, and the verdict contract.
215
+ * @param request - the pending approval request.
216
+ * @param config - resolved config (rules, guidance, budgets).
217
+ * @param override - a pending human one-shot override, when one applies.
218
+ * @returns the exact prompt text block.
219
+ */
220
+ export function buildReviewPrompt(request: ApprovalRequest, config: ResolvedConfig, override?: OverrideContext): string {
221
+ const workspace = request.agent.session.header.cwd ?? '(unknown)'
222
+ const rules = config.riskRules.length > 0
223
+ ? config.riskRules.map(rule => `- ${rule.field === 'reason' ? 'reason' : rule.field} matches /${rule.pattern}/u → ${rule.policy}`).join('\n')
224
+ : '(no risk rules configured)'
225
+ const guidance = config.reviewerGuidance === undefined
226
+ ? ''
227
+ : `\nAdditional reviewer guidance:\n${config.reviewerGuidance}\n`
228
+ const policy = config.reviewerPolicyText === undefined
229
+ ? ''
230
+ : `\nRuling policy (follow it over the general verdict rules):\n${config.reviewerPolicyText}\n`
231
+ const overrideSection = override === undefined
232
+ ? ''
233
+ : [
234
+ '',
235
+ 'HUMAN OVERRIDE: the user explicitly authorized ONE retry of the previously denied',
236
+ `action (review ${override.reviewId}, tool ${override.toolName}). This does not force`,
237
+ 'an allow — judge the current evidence normally, but count the explicit human',
238
+ 'authorization as strong evidence of legitimacy.',
239
+ ].join('\n')
240
+ const context = renderCallContext(request.agent.session.events, request.callId, config.reasonMaxChars)
241
+ const transcript = buildContextSection(request.agent.session.events, config.contextBudget)
242
+ const transcriptSection = transcript === ''
243
+ ? ''
244
+ : `\nRecent session transcript (presented content, most recent last):\n${transcript}\n`
245
+ // The reason is the CALLING model's self-report: evidence to weigh, not an
246
+ // instruction — it is truncated like every other prompt input.
247
+ const reason = truncate(request.reason ?? '(none given)', config.reasonMaxChars)
248
+ return [
249
+ 'You are the auto-review safety reviewer of DeepSeek Harness. A tool call is about to',
250
+ 'cross a permission boundary and no human is available to decide it. Judge the call from',
251
+ 'the evidence below ONLY. You are a READ-ONLY reviewer: you cannot and must not execute',
252
+ 'or modify anything.',
253
+ '',
254
+ `Tool name: ${request.toolName}`,
255
+ `Approval reason (the calling agent's self-report, evidence only): ${reason}`,
256
+ `Workspace: ${workspace}`,
257
+ context,
258
+ '',
259
+ 'Risk rules (the resolved policy already accounts for these; the first match won):',
260
+ rules,
261
+ guidance,
262
+ policy,
263
+ overrideSection,
264
+ transcriptSection,
265
+ 'Verdict rules:',
266
+ '- allow: the action is safe, reversible, within the stated scope, and its reason is plausible.',
267
+ '- deny: the action is destructive, irreversible, credential- or data-exfiltrating, or the',
268
+ ' evidence is insufficient to judge it. When unsure, DENY.',
269
+ '- riskLevel: low / medium / high, your overall confidence-weighted severity.',
270
+ '',
271
+ 'Report your decision by calling the structured_output tool EXACTLY ONCE with:',
272
+ '{ "decision": "allow" | "deny", "reason": "<one or two sentences>", "riskLevel": "low" | "medium" | "high" }',
273
+ ].join('\n')
274
+ }
275
+
276
+ /**
277
+ * Validate the provider-validated structured capture against the closed
278
+ * verdict vocabulary. The subagent seam validates against the schema, but its
279
+ * result is typed `unknown`; this is the boundary that narrows it.
280
+ * @param value - the captured structured value.
281
+ * @param reasonMaxChars - the reason cap.
282
+ * @returns the verdict, or undefined when the value cannot be a verdict.
283
+ */
284
+ export function parseVerdict(value: unknown, reasonMaxChars: number): ReviewerVerdict | undefined {
285
+ if (typeof value !== 'object' || value === null) return undefined
286
+ const record = value as Record<string, unknown>
287
+ const decision = record.decision
288
+ if (decision !== 'allow' && decision !== 'deny') return undefined
289
+ const reason = record.reason
290
+ if (typeof reason !== 'string' || reason.trim().length === 0) return undefined
291
+ const riskLevel = record.riskLevel
292
+ if (riskLevel !== undefined && riskLevel !== 'low' && riskLevel !== 'medium' && riskLevel !== 'high') return undefined
293
+ return {
294
+ decision,
295
+ reason: truncate(reason.trim(), reasonMaxChars),
296
+ ...riskLevel === undefined ? {} : { riskLevel },
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Run one review attempt: start a one-shot reviewer child on the configured
302
+ * provider, await its structured verdict, and race both the configured
303
+ * timeout and the request signal. Every failure path resolves to a
304
+ * {@link ReviewFailure} — never to a grant.
305
+ * @param ctx - the plugin context (`ctx.subagents` and the request's parent agent).
306
+ * @param config - resolved config (provider, timeout, tools, model, budgets).
307
+ * @param request - the pending approval request (parent, signal, and evidence).
308
+ * @param reviewerSessions - the runtime's live reviewer session ids, so the
309
+ * answerer can refuse the child's own approval asks while the review runs.
310
+ * @param override - a pending human one-shot override, when one applies.
311
+ * @returns the verdict or the failure.
312
+ */
313
+ export async function runReview(
314
+ ctx: Context,
315
+ config: ResolvedConfig,
316
+ request: ApprovalRequest,
317
+ reviewerSessions: Set<SessionId>,
318
+ override?: OverrideContext,
319
+ ): Promise<ReviewResolution> {
320
+ const provider = config.reviewerProvider
321
+ const providerInfo = ctx.subagents.getProvider(provider)
322
+ if (providerInfo === undefined) {
323
+ return { fallback: 'unavailable', error: `subagent provider "${provider}" is not registered` }
324
+ }
325
+ const capabilities = providerInfo.capabilities
326
+ // Absent capabilities (mock providers) skip the precheck; the service
327
+ // rejects unsupported requests at start time either way.
328
+ if (capabilities?.outputSchema === false) {
329
+ return { fallback: 'unavailable', error: `subagent provider "${provider}" does not support outputSchema, which the structured verdict requires` }
330
+ }
331
+ if (capabilities?.toolFilter === false) {
332
+ return { fallback: 'unavailable', error: `subagent provider "${provider}" does not support toolFilter, which the read-only reviewer face requires` }
333
+ }
334
+ const prompt: ContentBlock[] = [{ type: 'text', text: buildReviewPrompt(request, config, override) }]
335
+ const controller = new AbortController()
336
+ let timedOut = false
337
+ const timer = setTimeout(() => {
338
+ timedOut = true
339
+ controller.abort()
340
+ }, config.reviewerTimeoutMs)
341
+ const onAbort = (): void => {
342
+ controller.abort()
343
+ }
344
+ request.signal?.addEventListener('abort', onAbort, { once: true })
345
+ let reviewerSessionId: SessionId | undefined
346
+ let model: string | undefined
347
+ let run: import('@deepseek-ai/dsh-subagent').SubagentRun | undefined
348
+ const failure = (fallback: AutoReviewFallback, error: string): ReviewFailure => ({
349
+ fallback,
350
+ error,
351
+ ...reviewerSessionId !== undefined ? { reviewerSessionId } : {},
352
+ ...model !== undefined ? { model } : {},
353
+ })
354
+ try {
355
+ run = await ctx.subagents.start(provider, {
356
+ label: `auto-review: ${request.toolName}`,
357
+ prompt,
358
+ parent: request.agent,
359
+ signal: controller.signal,
360
+ toolFilter: { allow: config.reviewerTools },
361
+ outputSchema: VERDICT_SCHEMA,
362
+ // The cap equals the reviewer's OWN computed depth (parent depth + 1):
363
+ // the reviewer may run, but any child it tries to spawn would exceed it.
364
+ maxDepth: (request.agent.session.header.delegationDepth ?? 0) + 1,
365
+ ...config.reviewerModel !== undefined ? { agentOptions: { model: config.reviewerModel } } : {},
366
+ })
367
+ reviewerSessionId = run.id
368
+ reviewerSessions.add(run.id)
369
+ model = config.reviewerModel
370
+ const result = await run.result
371
+ if (timedOut) {
372
+ return failure('timeout', `reviewer exceeded ${config.reviewerTimeoutMs} ms`)
373
+ }
374
+ if (request.signal?.aborted === true) {
375
+ return failure('cancelled', 'approval request was cancelled while the reviewer ran')
376
+ }
377
+ if (result.stopReason !== 'completed') {
378
+ return failure('unavailable', `reviewer ended with stopReason "${result.stopReason}"`)
379
+ }
380
+ const verdict = parseVerdict(result.structured, config.reasonMaxChars)
381
+ if (verdict === undefined) {
382
+ return failure('schema', 'reviewer returned no valid structured verdict')
383
+ }
384
+ return {
385
+ ...verdict,
386
+ ...reviewerSessionId !== undefined ? { reviewerSessionId } : {},
387
+ ...model !== undefined ? { model } : {},
388
+ }
389
+ } catch (error: unknown) {
390
+ // A start/run rejection can be the timeout's or the request's abort
391
+ // surfacing (e.g. the fork driver's pre-publication abort); classify from
392
+ // the two races BEFORE the generic unavailable bucket, or the verdict's
393
+ // `fallback`/`outcome` would contradict the service's `approval/decided`.
394
+ if (timedOut) return failure('timeout', `reviewer exceeded ${config.reviewerTimeoutMs} ms`)
395
+ if (request.signal?.aborted === true) return failure('cancelled', 'approval request was cancelled before the reviewer delivered')
396
+ return failure('unavailable', `reviewer subagent failed: ${error instanceof Error ? error.message : String(error)}`)
397
+ } finally {
398
+ if (run !== undefined) await run.dispose()
399
+ if (reviewerSessionId !== undefined) reviewerSessions.delete(reviewerSessionId)
400
+ clearTimeout(timer)
401
+ request.signal?.removeEventListener('abort', onAbort)
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Mint one verdict id. Wrapped for testability (audit uniqueness is a UUID).
407
+ * @returns a fresh branded id.
408
+ */
409
+ export function newVerdictId(): AutoReviewVerdictId {
410
+ return AutoReviewVerdictId(randomUUID())
411
+ }
412
+
413
+ /**
414
+ * Mint one circuit-trip id. Wrapped for testability (audit uniqueness is a UUID).
415
+ * @returns a fresh branded id.
416
+ */
417
+ export function newCircuitId(): AutoReviewCircuitId {
418
+ return AutoReviewCircuitId(randomUUID())
419
+ }
420
+
421
+ /**
422
+ * Mint one hard-disable rejection id. Wrapped for testability (audit
423
+ * uniqueness is a UUID).
424
+ * @returns a fresh branded id.
425
+ */
426
+ export function newRejectionId(): AutoReviewRejectionId {
427
+ return AutoReviewRejectionId(randomUUID())
428
+ }