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/runtime.ts ADDED
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Runtime of `dsh-auto-review`: the `approval/request` answerer (claim when
3
+ * the session and tool policy say `ai`, otherwise strictly `next()`), the
4
+ * reviewer verdict handling with fail-closed fallback, the risk-policy
5
+ * escalation, the rejection circuit breaker, the deny-reason injection into
6
+ * the denied tool result, and the `/auto-review` session command. Every
7
+ * registration is an effect (ctx.on / commands.register).
8
+ * @module dsh-auto-review/runtime
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
13
+ import type { CallId } from '@deepseek-ai/dsh-llm'
14
+ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
15
+ import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session'
16
+ import type { ApprovalOutcome, ApprovalRequest, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
17
+ import type {} from '@deepseek-ai/dsh-user-approval'
18
+ import type {} from '@deepseek-ai/dsh-subagent'
19
+ import type { PostToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
20
+ import { resolveConfig, riskExceeds } from './config.ts'
21
+ import type { Config, ResolvedConfig, ToolReviewPolicy } from './config.ts'
22
+ import { messages } from './messages.ts'
23
+ import { makeAutoReviewProjection } from './projection.ts'
24
+ import {
25
+ activeOverride,
26
+ autoReviewFailuresInOpenTurn,
27
+ autoReviewsInOpenTurn,
28
+ circuitInOpenTurn,
29
+ circuitResultText,
30
+ consecutiveDeniesInOpenTurn,
31
+ correlateApprovalId,
32
+ deniesInRecentVerdicts,
33
+ denyResultText,
34
+ effectiveAutoReviewState,
35
+ fallbackResultText,
36
+ lastDeniedVerdicts,
37
+ neverResultText,
38
+ reviewStats,
39
+ type AutoReviewFallback,
40
+ type AutoReviewVerdictId,
41
+ type CircuitAppend,
42
+ type OverrideAppend,
43
+ type RejectionAppend,
44
+ type StateAppend,
45
+ type VerdictAppend,
46
+ } from './events.ts'
47
+ import { isReviewFailure, newCircuitId, newRejectionId, newVerdictId, runReview, sanitizedArgumentsText, truncate } from './review.ts'
48
+ import type { OverrideContext, ReviewFailure } from './review.ts'
49
+
50
+ export const name = 'auto-review'
51
+ export const inject = ['approval', 'subagents', 'commands', 'tools']
52
+
53
+ /** How long an injected feedback text stays available for its tool result (bounded cleanup). */
54
+ const FEEDBACK_TTL_MS = 5 * 60_000
55
+
56
+ /** One recorded feedback text waiting for the denied/failed call's tool result. */
57
+ interface FeedbackEntry {
58
+ readonly text: string
59
+ readonly at: number
60
+ }
61
+
62
+ /** Closed-union backstop for the fallback-policy switch. */
63
+ function assertNever(value: never, label: string): never {
64
+ throw new TypeError(`unknown ${label}: ${String(value)}`)
65
+ }
66
+
67
+ /** One policy resolution: the policy plus which rule or table entry produced it. */
68
+ interface ResolvedPolicy {
69
+ readonly policy: ToolReviewPolicy
70
+ /** The matched risk rule or policy table entry, for prompts and the never-rejection audit. */
71
+ readonly source: string
72
+ }
73
+
74
+ /**
75
+ * Answerer policy resolution for one request: the first matching risk rule
76
+ * (security invariants win over tool defaults), then the exact tool override,
77
+ * then the table default. Each rule matches its configured field: the
78
+ * request reason, the tool name, or the redacted presented call arguments.
79
+ * @param config - the resolved config.
80
+ * @param request - the pending approval request.
81
+ * @param argumentsText - the sanitized call-arguments JSON, when the log has it.
82
+ * @returns the policy this request resolves to, with its provenance.
83
+ */
84
+ function policyFor(config: ResolvedConfig, request: ApprovalRequest, argumentsText: string | undefined): ResolvedPolicy {
85
+ for (const rule of config.riskRules) {
86
+ const subject = rule.field === 'reason'
87
+ ? request.reason ?? ''
88
+ : rule.field === 'toolName'
89
+ ? request.toolName
90
+ : argumentsText ?? ''
91
+ if (rule.regex.test(subject)) return { policy: rule.policy, source: `risk rule /${rule.pattern}/ (${rule.field})` }
92
+ }
93
+ const override = config.toolsPolicy.overrides[request.toolName]
94
+ if (override !== undefined) return { policy: override, source: `toolsPolicy.overrides.${request.toolName}` }
95
+ return { policy: config.toolsPolicy.default, source: 'toolsPolicy.default' }
96
+ }
97
+
98
+ /**
99
+ * Answerer state and behavior. One instance per plugin mount; disposals are
100
+ * owned by the ctx.on / commands.register effects in {@link apply}.
101
+ */
102
+ export class AutoReviewRuntime {
103
+ /** Live reviewer child sessions — their approval asks never re-enter AI review. */
104
+ private readonly reviewerSessions = new Set<SessionId>()
105
+
106
+ /** Feedback texts keyed by call id, consumed (and deleted) by the post-execute listener. */
107
+ private readonly feedback = new Map<CallId, FeedbackEntry>()
108
+
109
+ constructor(
110
+ private readonly ctx: Context,
111
+ readonly config: ResolvedConfig,
112
+ ) {}
113
+
114
+ /**
115
+ * The `approval/request` answerer. Claims a request ONLY when auto-review
116
+ * is enabled for the session AND the policy for this tool/reason is `ai`
117
+ * AND both per-turn budgets remain AND the rejection circuit breaker is
118
+ * not tripped; everything else delegates via `next()` (or deterministically
119
+ * rejects for `never`). The reviewer's own asks are always delegated
120
+ * (anti-recursion).
121
+ * @param request - the pending decision.
122
+ * @param next - the downstream answerer chain.
123
+ * @returns the closed approval outcome.
124
+ */
125
+ answer(request: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
126
+ // Anti-recursion: a reviewer child's own approval asks go to the human chain.
127
+ if (this.reviewerSessions.has(request.agent.id)) return next()
128
+ const session = request.agent.session
129
+ const enabled = effectiveAutoReviewState(session.events) ?? this.config.enableByDefault
130
+ if (!enabled) return next()
131
+ const needsArguments = this.config.riskRules.some(rule => rule.field === 'arguments')
132
+ const argumentsText = needsArguments && request.callId !== undefined
133
+ ? sanitizedArgumentsText(session.events, request.callId)
134
+ : undefined
135
+ const { policy, source } = policyFor(this.config, request, argumentsText)
136
+ if (policy === 'never') return this.neverReject(request, source)
137
+ if (policy !== 'ai') return next()
138
+ // Separate budgets: real AI verdicts vs reviewer failures. A broken
139
+ // reviewer burns the failure budget (then delegates) without eating the
140
+ // AI-decision budget, and vice versa.
141
+ if (autoReviewsInOpenTurn(session.events) >= this.config.maxReviewsPerTurn) return next()
142
+ if (autoReviewFailuresInOpenTurn(session.events) >= this.config.maxFailuresPerTurn) return next()
143
+ const circuit = circuitInOpenTurn(session.events)
144
+ if (circuit !== undefined) return this.circuitSettle(request, circuit, next)
145
+ const approvalId = correlateApprovalId(session.events, request.toolName, request.callId)
146
+ if (approvalId === undefined) {
147
+ // The audit chain cannot be completed (verdict → approval/asked); treat
148
+ // as an internal unavailability, never as a grant.
149
+ return this.finish(request, undefined, {
150
+ fallback: 'unavailable',
151
+ error: 'cannot correlate the pending approval/asked audit event',
152
+ }, next)
153
+ }
154
+ const overrideId = activeOverride(session.events, request.toolName, this.config.overrideTtlMs, Date.now())
155
+ const override: OverrideContext | undefined = overrideId === undefined
156
+ ? undefined
157
+ : { reviewId: overrideId, toolName: request.toolName }
158
+ return this.review(request, approvalId, next, override)
159
+ }
160
+
161
+ /**
162
+ * Settle a `never`-policy request deterministically: record the log-only
163
+ * `autoReview/rejection` audit event, and feed a model-visible
164
+ * `[auto-review-never]` marker text (plus the deny guidance) into the
165
+ * denied call's tool result — without it the model only sees the generic
166
+ * rejection and keeps retrying a hard-disabled action. No reviewer runs
167
+ * and no budget is consumed.
168
+ * @param request - the hard-disabled decision.
169
+ * @param source - the matched risk rule or policy table entry.
170
+ * @returns the closed rejection.
171
+ */
172
+ private neverReject(request: ApprovalRequest, source: string): Promise<ApprovalOutcome> {
173
+ const rejectionId = newRejectionId()
174
+ const reason = truncate(source, this.config.reasonMaxChars)
175
+ const approvalId = correlateApprovalId(request.agent.session.events, request.toolName, request.callId)
176
+ ;(request.agent.session.append as unknown as RejectionAppend)('autoReview/rejection', {
177
+ rejectionId,
178
+ ...approvalId !== undefined ? { approvalId } : {},
179
+ toolName: request.toolName,
180
+ ...request.callId !== undefined ? { callId: request.callId } : {},
181
+ reason,
182
+ outcome: 'rejected',
183
+ }, { ignorable: true })
184
+ this.recordFeedback(request.callId, `${neverResultText(rejectionId, request.toolName, reason)}\n${this.config.denyGuidance}`)
185
+ return Promise.resolve<ApprovalOutcome>('rejected')
186
+ }
187
+
188
+ /**
189
+ * Run the reviewer for one claimed request, register the child for
190
+ * anti-recursion, apply the risk policy, record the verdict, trip the
191
+ * circuit breaker on denials, and settle the answerer chain.
192
+ * @param request - the pending decision.
193
+ * @param approvalId - the correlated `approval/asked` id.
194
+ * @param next - the downstream chain (used by risk-policy delegation).
195
+ * @param override - a pending human one-shot override, when one applies.
196
+ * @returns the closed approval outcome.
197
+ */
198
+ private async review(
199
+ request: ApprovalRequest,
200
+ approvalId: ApprovalRequestId,
201
+ next: () => Promise<ApprovalOutcome>,
202
+ override?: OverrideContext,
203
+ ): Promise<ApprovalOutcome> {
204
+ const started = Date.now()
205
+ const resolution = await runReview(this.ctx, this.config, request, this.reviewerSessions, override)
206
+ const durationMs = Date.now() - started
207
+ if (isReviewFailure(resolution)) {
208
+ return this.finish(request, approvalId, resolution, next, durationMs)
209
+ }
210
+ const reviewId = newVerdictId()
211
+ const overridden = resolution.decision === 'allow'
212
+ && resolution.riskLevel !== undefined
213
+ && riskExceeds(resolution.riskLevel, this.config.riskPolicy.maxAutoAllow)
214
+ const outcome: ApprovalOutcome | undefined = overridden
215
+ ? this.config.riskPolicy.onHighRisk === 'deny' ? 'rejected' : undefined
216
+ : resolution.decision === 'allow' ? 'allowed-once' : 'rejected'
217
+ ;(request.agent.session.append as unknown as VerdictAppend)('autoReview/verdict', {
218
+ reviewId,
219
+ approvalId,
220
+ toolName: request.toolName,
221
+ ...request.callId !== undefined ? { callId: request.callId } : {},
222
+ provider: this.config.reviewerProvider,
223
+ ...resolution.model !== undefined ? { model: resolution.model } : {},
224
+ ...resolution.reviewerSessionId !== undefined ? { reviewerSessionId: resolution.reviewerSessionId } : {},
225
+ durationMs,
226
+ decision: resolution.decision,
227
+ reason: resolution.reason,
228
+ ...resolution.riskLevel !== undefined ? { riskLevel: resolution.riskLevel } : {},
229
+ ...overridden ? { escalation: 'risk-policy' } : {},
230
+ ...outcome !== undefined ? { outcome } : {},
231
+ }, { ignorable: true })
232
+ if (outcome === undefined) return next()
233
+ if (resolution.decision === 'deny') {
234
+ this.recordDenyReason(request.callId, request.toolName, reviewId, resolution.reason)
235
+ this.checkCircuit(request)
236
+ return 'rejected'
237
+ }
238
+ if (overridden) {
239
+ // The reviewer allowed, but the risk policy denied: feed the override
240
+ // back to the model and count it toward the rejection circuit breaker.
241
+ this.recordDenyReason(
242
+ request.callId,
243
+ request.toolName,
244
+ reviewId,
245
+ `${resolution.reason} (risk ${resolution.riskLevel} exceeds the configured maxAutoAllow ${this.config.riskPolicy.maxAutoAllow}; the allow verdict was overridden)`,
246
+ )
247
+ this.checkCircuit(request)
248
+ return 'rejected'
249
+ }
250
+ return 'allowed-once'
251
+ }
252
+
253
+ /**
254
+ * Append the fallback verdict event and apply the configured fallback
255
+ * policy. A user cancellation settles `cancelled` regardless of policy;
256
+ * otherwise `rejected` (fail closed), `allow-once`, or `delegate`
257
+ * (continue the chain) per config.
258
+ */
259
+ private finish(
260
+ request: ApprovalRequest,
261
+ approvalId: ApprovalRequestId | undefined,
262
+ failure: ReviewFailure,
263
+ next: () => Promise<ApprovalOutcome>,
264
+ durationMs = 0,
265
+ ): Promise<ApprovalOutcome> {
266
+ const outcome = this.fallbackOutcome(failure)
267
+ if (approvalId !== undefined) {
268
+ const reviewId = newVerdictId()
269
+ ;(request.agent.session.append as unknown as VerdictAppend)('autoReview/verdict', {
270
+ reviewId,
271
+ approvalId,
272
+ toolName: request.toolName,
273
+ ...request.callId !== undefined ? { callId: request.callId } : {},
274
+ provider: this.config.reviewerProvider,
275
+ ...failure.model !== undefined ? { model: failure.model } : {},
276
+ ...failure.reviewerSessionId !== undefined ? { reviewerSessionId: failure.reviewerSessionId } : {},
277
+ durationMs,
278
+ fallback: failure.fallback,
279
+ error: failure.error,
280
+ ...outcome !== undefined ? { outcome } : {},
281
+ }, { ignorable: true })
282
+ this.ctx.logger.warn(`auto-review fallback (${failure.fallback}) for ${request.toolName}: ${failure.error}`)
283
+ // A fail-closed rejection is as model-visible as a reviewer deny: the
284
+ // agent learns WHY it was rejected (and that the reviewer failed)
285
+ // instead of retrying the same escalation blindly.
286
+ if (outcome === 'rejected') {
287
+ this.recordFallbackFeedback(request.callId, reviewId, failure.fallback, failure.error)
288
+ }
289
+ }
290
+ if (outcome === undefined) return next()
291
+ return Promise.resolve(outcome)
292
+ }
293
+
294
+ /**
295
+ * Map one reviewer failure to the approval outcome the answerer settles
296
+ * with: `cancelled` when the user aborted the request; otherwise the
297
+ * configured fallback policy.
298
+ * @param failure - the review failure.
299
+ * @returns the outcome, or undefined when the chain must continue.
300
+ */
301
+ private fallbackOutcome(failure: ReviewFailure): ApprovalOutcome | undefined {
302
+ if (failure.fallback === 'cancelled') return 'cancelled'
303
+ switch (this.config.fallbackPolicy) {
304
+ case 'rejected': return 'rejected'
305
+ case 'allow-once': return 'allowed-once'
306
+ case 'delegate': return undefined
307
+ default: return assertNever(this.config.fallbackPolicy, 'fallback policy')
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Settle one request while the rejection circuit breaker is tripped:
313
+ * `delegate` continues the chain; `reject` (and `abort-turn`, whose abort
314
+ * already happened at trip time) rejects with an auditable marker.
315
+ * @param request - the pending decision.
316
+ * @param circuit - the turn's recorded circuit trip.
317
+ * @param next - the downstream chain.
318
+ * @returns the closed approval outcome.
319
+ */
320
+ private circuitSettle(
321
+ request: ApprovalRequest,
322
+ circuit: SessionEventMap['autoReview/circuit'],
323
+ next: () => Promise<ApprovalOutcome>,
324
+ ): Promise<ApprovalOutcome> {
325
+ if (circuit.action === 'delegate') return next()
326
+ const explanation = `rejection circuit breaker tripped (${circuit.trip.kind}: ${circuit.trip.count} denials)`
327
+ this.recordFeedback(request.callId, circuitResultText(circuit.circuitId, request.toolName, explanation))
328
+ return Promise.resolve<ApprovalOutcome>('rejected')
329
+ }
330
+
331
+ /**
332
+ * Trip the rejection circuit breaker after a denial, at most once per
333
+ * turn. `abort-turn` also injects a model-visible warning and cancels the
334
+ * agent (deferred a macrotask so the service's `approval/decided` append
335
+ * commits first — the pair must stay turn-enclosed).
336
+ * @param request - the request whose denial may trip the breaker.
337
+ */
338
+ private checkCircuit(request: ApprovalRequest): void {
339
+ const session = request.agent.session
340
+ if (circuitInOpenTurn(session.events) !== undefined) return
341
+ const { consecutiveDenies, windowDenies, windowSize, action } = this.config.circuitBreaker
342
+ const consecutive = consecutiveDeniesInOpenTurn(session.events)
343
+ const trip = consecutive >= consecutiveDenies
344
+ ? { kind: 'consecutive' as const, count: consecutive }
345
+ : (() => {
346
+ const window = deniesInRecentVerdicts(session.events, windowSize)
347
+ return window >= windowDenies ? { kind: 'window' as const, count: window } : undefined
348
+ })()
349
+ if (trip === undefined) return
350
+ const circuitId = newCircuitId()
351
+ ;(session.append as unknown as CircuitAppend)('autoReview/circuit', {
352
+ circuitId,
353
+ action,
354
+ trip,
355
+ toolName: request.toolName,
356
+ }, { ignorable: true })
357
+ this.ctx.logger.warn(`auto-review circuit breaker tripped (${trip.kind}: ${trip.count}) by ${request.toolName}; action=${action}`)
358
+ if (action === 'abort-turn') {
359
+ request.agent.inject(createUserMessage({
360
+ content: [{ type: 'text', text: messages(this.config.language).circuitNotice(trip.kind, trip.count) }],
361
+ source: { kind: 'plugin', plugin: 'auto-review' },
362
+ }))
363
+ setTimeout(() => {
364
+ request.agent.cancel({ kind: 'hook', reason: `auto-review circuit breaker: ${trip.kind} ${trip.count}` })
365
+ }, 0)
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Store one model-visible feedback text for a denied/failed call's tool
371
+ * result, keyed by call id. Consumed once by the post-execute listener;
372
+ * entries expire after a bounded TTL so a result path that never reaches
373
+ * post-execute cannot grow the map. Same call id twice is last-wins (a
374
+ * retried approval replaces its own pending feedback).
375
+ */
376
+ private recordFeedback(callId: CallId | undefined, text: string): void {
377
+ if (callId === undefined) return
378
+ const now = Date.now()
379
+ for (const [key, entry] of this.feedback) {
380
+ if (now - entry.at > FEEDBACK_TTL_MS) this.feedback.delete(key)
381
+ }
382
+ this.feedback.set(callId, { text, at: now })
383
+ }
384
+
385
+ /**
386
+ * Record a deny reason (plus the anti-circumvention guidance) for the
387
+ * denied call's tool result.
388
+ * @param callId - the denied call.
389
+ * @param toolName - the denied tool.
390
+ * @param reviewId - the verdict event's id (embedded in the injected text).
391
+ * @param reason - the reviewer's reason.
392
+ */
393
+ private recordDenyReason(
394
+ callId: CallId | undefined,
395
+ toolName: string,
396
+ reviewId: AutoReviewVerdictId,
397
+ reason: string,
398
+ ): void {
399
+ this.recordFeedback(callId, `${denyResultText(reviewId, toolName, reason)}\n${this.config.denyGuidance}`)
400
+ }
401
+
402
+ /**
403
+ * Record a fallback-rejection text for the failed call's tool result.
404
+ * @param callId - the failed call.
405
+ * @param reviewId - the fallback verdict event's id (embedded in the text).
406
+ * @param fallback - the failure category.
407
+ * @param error - the failure detail.
408
+ */
409
+ private recordFallbackFeedback(
410
+ callId: CallId | undefined,
411
+ reviewId: AutoReviewVerdictId,
412
+ fallback: AutoReviewFallback,
413
+ error: string,
414
+ ): void {
415
+ this.recordFeedback(callId, fallbackResultText(reviewId, fallback, truncate(error, this.config.reasonMaxChars)))
416
+ }
417
+
418
+ /**
419
+ * The `tools/post-execute` listener: replace the generic rejection text of
420
+ * a call THIS answerer denied with the recorded feedback (the model-visible
421
+ * content, reconstructable via the embedded marker). Any other call
422
+ * delegates untouched.
423
+ */
424
+ injectDenyReason(
425
+ exec: ToolExecution,
426
+ result: Readonly<ToolExecutionResult>,
427
+ next: () => Promise<PostToolDecision>,
428
+ ): Promise<PostToolDecision> {
429
+ const entry = this.feedback.get(exec.callId)
430
+ if (entry === undefined) return next()
431
+ this.feedback.delete(exec.callId)
432
+ // Only the denial result exists for this call (the tool never executed);
433
+ // a non-error result means the chain evolved past us — leave it alone.
434
+ if (!result.isError) return next()
435
+ return Promise.resolve<PostToolDecision>({
436
+ kind: 'block',
437
+ feedback: [{ type: 'text', text: entry.text }],
438
+ })
439
+ }
440
+
441
+ /**
442
+ * Execute the `/auto-review` command: `on` / `off` write the durable
443
+ * `autoReview/state` override (surviving restore) and inject a switch
444
+ * notice; `status` reports the effective state, both per-turn budgets,
445
+ * and the session's cumulative statistics; `approve [n]` records a
446
+ * one-shot human override for a recent denial.
447
+ * @param invocation - the received command invocation.
448
+ * @returns the command result shown to the user.
449
+ */
450
+ command(invocation: CommandInvocation): CommandResult {
451
+ const agent = invocation.agent
452
+ const session = agent.session
453
+ const t = messages(this.config.language)
454
+ const input = invocation.rawInput.trim().toLowerCase()
455
+ if (input.startsWith('approve')) return this.approveCommand(invocation)
456
+ const current = effectiveAutoReviewState(session.events) ?? this.config.enableByDefault
457
+ if (input === 'status' || input === '') {
458
+ const stats = reviewStats(session.events)
459
+ const circuit = circuitInOpenTurn(session.events)
460
+ const recent = stats.recent.length === 0
461
+ ? []
462
+ : [t.recentLine(stats.recent.map(verdict => {
463
+ const label = verdict.decision !== undefined
464
+ ? verdict.decision
465
+ : `fallback(${verdict.fallback ?? '?'})`
466
+ return `${verdict.toolName}: ${label}`
467
+ }).join(', '))]
468
+ return {
469
+ kind: 'success',
470
+ text: [
471
+ t.statusLine(current),
472
+ t.verdictsLine(autoReviewsInOpenTurn(session.events), this.config.maxReviewsPerTurn),
473
+ t.failuresLine(autoReviewFailuresInOpenTurn(session.events), this.config.maxFailuresPerTurn),
474
+ ...circuit === undefined ? [] : [t.circuitLine(circuit.trip.kind, circuit.trip.count, circuit.action)],
475
+ t.allTimeLine(stats.allows, stats.denies, stats.fallbacks, stats.rejections, stats.avgDurationMs),
476
+ ...recent,
477
+ t.usage,
478
+ ].join('\n'),
479
+ }
480
+ }
481
+ if (input !== 'on' && input !== 'off') {
482
+ return {
483
+ kind: 'error',
484
+ text: t.unknownArg(invocation.rawInput.trim()),
485
+ }
486
+ }
487
+ const enabled = input === 'on'
488
+ if (current === enabled) {
489
+ return { kind: 'success', text: t.already(input.toUpperCase()) }
490
+ }
491
+ ;(session.append as unknown as StateAppend)('autoReview/state', { enabled }, { ignorable: true })
492
+ agent.inject(createUserMessage({
493
+ content: [{
494
+ type: 'text',
495
+ text: t.switchedNotice(enabled),
496
+ }],
497
+ source: { kind: 'plugin', plugin: 'auto-review' },
498
+ }))
499
+ return { kind: 'success', text: t.switchedResult(input.toUpperCase()) }
500
+ }
501
+
502
+ /**
503
+ * Execute `/auto-review approve [n]`: record a single-use human override
504
+ * for the n-th most recent denial (1 = most recent). The override is
505
+ * consumed by the NEXT same-tool review within `overrideTtlMs`, which
506
+ * carries the authorization as reviewer context — the reviewer still
507
+ * decides.
508
+ * @param invocation - the received command invocation.
509
+ * @returns the command result shown to the user.
510
+ */
511
+ private approveCommand(invocation: CommandInvocation): CommandResult {
512
+ const agent = invocation.agent
513
+ const session = agent.session
514
+ const t = messages(this.config.language)
515
+ const arg = invocation.rawInput.trim().split(/\s+/u)[1]
516
+ const index = arg === undefined ? 1 : Number.parseInt(arg, 10)
517
+ if (!Number.isSafeInteger(index) || index < 1) {
518
+ return {
519
+ kind: 'error',
520
+ text: t.approveInvalid(arg ?? ''),
521
+ }
522
+ }
523
+ const denies = lastDeniedVerdicts(session.events, index)
524
+ const target = denies[index - 1]
525
+ if (target === undefined) {
526
+ return { kind: 'error', text: t.approveNone(index, denies.length) }
527
+ }
528
+ ;(session.append as unknown as OverrideAppend)('autoReview/override', {
529
+ reviewId: target.reviewId,
530
+ toolName: target.toolName,
531
+ }, { ignorable: true })
532
+ return {
533
+ kind: 'success',
534
+ text: t.approveResult(target.toolName, String(target.reviewId), Math.round(this.config.overrideTtlMs / 60_000)),
535
+ }
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Mount the plugin: resolve config, register the answerer and the
541
+ * post-execute listener as effects, register the slash command, and register
542
+ * the `autoReview` session projection (when the host provides the
543
+ * session-projection capability — the web profile does; bare test mounts and
544
+ * minimal compositions may not, and the answerer must not depend on it).
545
+ * @param ctx - the host context.
546
+ * @param config - raw plugin config.
547
+ */
548
+ export function apply(ctx: Context, config: Config): void {
549
+ const resolved = resolveConfig(config)
550
+ const runtime = new AutoReviewRuntime(ctx, resolved)
551
+ ctx.on('approval/request', (request, next) => runtime.answer(request, next))
552
+ ctx.on('tools/post-execute', (exec, result, next) => runtime.injectDenyReason(exec, result, next))
553
+ ctx.commands.register({
554
+ name: 'auto-review',
555
+ description: messages(resolved.language).description,
556
+ input: { hint: 'on|off|status|approve [n]' },
557
+ handler: invocation => runtime.command(invocation),
558
+ })
559
+ if (ctx.get('sessionProjections') !== undefined) {
560
+ ctx.inject(['sessionProjections'], scope => scope.sessionProjections.register(makeAutoReviewProjection(resolved.enableByDefault)))
561
+ }
562
+ }