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/config.ts ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Config schema and resolution for `dsh-auto-review`. Every tunable is a
3
+ * validated {@link Config} field changeable from cordis.yml; the resolution
4
+ * step compiles risk-rule regexes and fails loud on invalid input.
5
+ * @module dsh-auto-review/config
6
+ */
7
+
8
+ import z from '@deepseek-ai/schemastery'
9
+ import type { UiLanguage } from './messages.ts'
10
+
11
+ /**
12
+ * What one approval request does at the auto-review answerer:
13
+ *
14
+ * - `'ai'` — the AI reviewer decides (allow/deny) and the request never
15
+ * reaches a human answerer.
16
+ * - `'human'` — the request is delegated (`next()`) to the human answerer;
17
+ * auto-review does not short-circuit the chain.
18
+ * - `'never'` — the answerer rejects deterministically. Only for tools an
19
+ * admin wants hard-disabled whenever approval is required.
20
+ */
21
+ export type ToolReviewPolicy = 'ai' | 'human' | 'never'
22
+
23
+ /** Risk levels the reviewer may attach to a verdict. */
24
+ export type RiskLevel = 'low' | 'medium' | 'high'
25
+
26
+ /** The risk-level ordering, low first — the comparison base of {@link RiskPolicyConfig}. */
27
+ export const RISK_ORDER: readonly RiskLevel[] = ['low', 'medium', 'high']
28
+
29
+ /** What a risk-rule pattern is matched against. */
30
+ export type RiskRuleField = 'reason' | 'toolName' | 'arguments'
31
+
32
+ /**
33
+ * What happens when the reviewer cannot deliver a verdict (crash, timeout,
34
+ * subagent unavailable, schema mismatch):
35
+ *
36
+ * - `'rejected'` — fail closed; the request is denied (the default).
37
+ * - `'delegate'` — the request is passed down the answerer chain (a human
38
+ * answerer, when one is composed).
39
+ * - `'allow-once'` — the request is granted once. Deliberately dangerous:
40
+ * the grant is unconditional, not "readonly" in any enforced sense; it
41
+ * exists for unattended deployments whose admin accepts that risk. Renamed
42
+ * from `allow-readonly` in 0.2.0 for honesty; the old spelling is rejected
43
+ * loudly.
44
+ */
45
+ export type FallbackPolicy = 'rejected' | 'delegate' | 'allow-once'
46
+
47
+ /**
48
+ * How much of the session transcript the reviewer receives as compact
49
+ * context (the calling session's recent user/assistant messages and tool
50
+ * results, redacted and truncated). `turns: 0` disables the section.
51
+ */
52
+ export interface ContextBudgetConfig {
53
+ /** How many completed turns (plus the current one) of transcript to include; 0 disables. */
54
+ readonly turns: number
55
+ /** Character cap applied to the whole context section. */
56
+ readonly maxChars: number
57
+ }
58
+
59
+ /** What a tripped rejection circuit breaker does to later ai-policy requests in the turn. */
60
+ export type CircuitAction = 'delegate' | 'reject' | 'abort-turn'
61
+
62
+ /**
63
+ * The rejection circuit breaker: after a run of denials the turn stops
64
+ * asking the reviewer. Trips on {@link consecutiveDenies} consecutive deny
65
+ * verdicts in the turn OR {@link windowDenies} denials inside the last
66
+ * {@link windowSize} verdicts of the turn, whichever comes first.
67
+ */
68
+ export interface CircuitBreakerConfig {
69
+ /** Consecutive deny verdicts in one turn that trip the breaker. */
70
+ readonly consecutiveDenies: number
71
+ /** Deny count inside the window that trips the breaker. */
72
+ readonly windowDenies: number
73
+ /** How many recent verdicts the window counts. */
74
+ readonly windowSize: number
75
+ /** What later requests in the turn do once tripped. */
76
+ readonly action: CircuitAction
77
+ }
78
+
79
+ /**
80
+ * How the reviewer's verdict risk level constrains the outcome: an `allow`
81
+ * verdict whose risk exceeds {@link maxAutoAllow} never settles the request
82
+ * — it delegates to the human chain (or denies) per {@link onHighRisk}.
83
+ */
84
+ export interface RiskPolicyConfig {
85
+ /** The highest risk level an allow verdict may settle with. */
86
+ readonly maxAutoAllow: RiskLevel
87
+ /** What happens to an allow verdict above the cap. */
88
+ readonly onHighRisk: 'delegate' | 'deny'
89
+ }
90
+
91
+ /** One risk rule: a regex matched against the request reason, tool name, or redacted call arguments. */
92
+ export interface RiskRuleConfig {
93
+ /** Regular expression source (compiled without flags). */
94
+ pattern: string
95
+ /** The policy a matching request resolves to. */
96
+ policy: ToolReviewPolicy
97
+ /** What the pattern is matched against. Default `'reason'`; `'arguments'` matches the redacted presented call arguments. */
98
+ field?: RiskRuleField
99
+ }
100
+
101
+ /** Per-tool policy table: explicit overrides, then a default for unlisted tools. */
102
+ export interface ToolsPolicyConfig {
103
+ /** Policy for tools not named in {@link overrides}. Default `'human'` (delegate). */
104
+ default: ToolReviewPolicy
105
+ /** Exact tool-name → policy mapping. */
106
+ overrides: Record<string, ToolReviewPolicy>
107
+ }
108
+
109
+ /** Raw plugin config — every field optional; {@link Config} supplies the defaults. */
110
+ export interface Config {
111
+ /**
112
+ * Whether sessions start with auto-review enabled. The `/auto-review`
113
+ * command writes a durable per-session override that beats this default.
114
+ */
115
+ enableByDefault?: boolean
116
+ /** Tool policy table (defaults + per-tool overrides). */
117
+ toolsPolicy?: Partial<ToolsPolicyConfig>
118
+ /** Risk rules matched (first match wins) against the request reason before the tool table. */
119
+ riskRules?: RiskRuleConfig[]
120
+ /** Subagent provider name for the reviewer (default `fork`, the in-process fork backend). */
121
+ reviewerProvider?: string
122
+ /** Reviewer model id; when unset the reviewer inherits the session agent's route. */
123
+ reviewerModel?: string
124
+ /** Verdict deadline in milliseconds. Default 60000. */
125
+ reviewerTimeoutMs?: number
126
+ /**
127
+ * The reviewer's tool allow-list — read-only tools by default. The subagent
128
+ * `toolFilter` is an allow-list: anything not named here is invisible to
129
+ * and unexecutable by the reviewer child. Must be non-empty.
130
+ */
131
+ reviewerTools?: string[]
132
+ /** Reviewer failure policy (see {@link FallbackPolicy}). Default `'rejected'`. */
133
+ fallbackPolicy?: FallbackPolicy
134
+ /** Maximum real AI verdicts per open turn; further requests delegate to humans. */
135
+ maxReviewsPerTurn?: number
136
+ /**
137
+ * Maximum reviewer failures (timeout/unavailable/schema — not user
138
+ * cancellations) per open turn; beyond it, further requests delegate
139
+ * instead of paying another full timeout. Defaults to
140
+ * {@link maxReviewsPerTurn}.
141
+ */
142
+ maxFailuresPerTurn?: number
143
+ /** Cap applied to reviewer reasons, the request reason, and the redacted argument preview before they enter prompts or logs. */
144
+ reasonMaxChars?: number
145
+ /** Optional extra guidance appended to the reviewer prompt (advisory, not a hard rule). */
146
+ reviewerGuidance?: string
147
+ /**
148
+ * Optional policy text injected into the reviewer prompt as the ruling
149
+ * policy (Markdown: always-deny / always-allow / require-justification
150
+ * sections), like Codex `auto_review.policy`. A template ships at
151
+ * `fixtures/config/policy-template.md`.
152
+ */
153
+ reviewerPolicyText?: string
154
+ /** Guidance appended to every injected deny reason (anti-circumvention). */
155
+ denyGuidance?: string
156
+ /** Compact transcript budget for the reviewer prompt (see {@link ContextBudgetConfig}). */
157
+ contextBudget?: Partial<ContextBudgetConfig>
158
+ /** Verdict risk-level constraints (see {@link RiskPolicyConfig}). */
159
+ riskPolicy?: Partial<RiskPolicyConfig>
160
+ /** The rejection circuit breaker (see {@link CircuitBreakerConfig}). */
161
+ circuitBreaker?: Partial<CircuitBreakerConfig>
162
+ /** How long a `/auto-review approve` override stays usable, in milliseconds. Default 5 minutes. */
163
+ overrideTtlMs?: number
164
+ /** UI language of the `/auto-review` command output (`en` | `zh`). Default `'en'`. */
165
+ language?: UiLanguage
166
+ }
167
+
168
+ /** Config after {@link resolveConfig}: every optional field has its explicit default. */
169
+ export interface ResolvedConfig {
170
+ readonly enableByDefault: boolean
171
+ readonly toolsPolicy: ToolsPolicyConfig
172
+ readonly riskRules: readonly ResolvedRiskRule[]
173
+ readonly reviewerProvider: string
174
+ readonly reviewerModel: string | undefined
175
+ readonly reviewerTimeoutMs: number
176
+ readonly reviewerTools: readonly string[]
177
+ readonly fallbackPolicy: FallbackPolicy
178
+ readonly maxReviewsPerTurn: number
179
+ readonly maxFailuresPerTurn: number
180
+ readonly reasonMaxChars: number
181
+ readonly reviewerGuidance: string | undefined
182
+ readonly reviewerPolicyText: string | undefined
183
+ readonly denyGuidance: string
184
+ readonly contextBudget: ContextBudgetConfig
185
+ readonly riskPolicy: RiskPolicyConfig
186
+ readonly circuitBreaker: CircuitBreakerConfig
187
+ readonly overrideTtlMs: number
188
+ readonly language: UiLanguage
189
+ }
190
+
191
+ /** A compiled {@link RiskRuleConfig}, ready to test against its configured field. */
192
+ export interface ResolvedRiskRule {
193
+ /** The original pattern source, kept for prompts and audit. */
194
+ readonly pattern: string
195
+ readonly regex: RegExp
196
+ readonly policy: ToolReviewPolicy
197
+ readonly field: RiskRuleField
198
+ }
199
+
200
+ const POLICY = z.union(['ai', 'human', 'never'] as const)
201
+ const FALLBACK = z.union(['rejected', 'delegate', 'allow-once'] as const)
202
+ const RISK = z.union(['low', 'medium', 'high'] as const)
203
+ const FIELD = z.union(['reason', 'toolName', 'arguments'] as const)
204
+ const CIRCUIT_ACTION = z.union(['delegate', 'reject', 'abort-turn'] as const)
205
+
206
+ /** Default anti-circumvention guidance appended to every injected deny reason. */
207
+ export const DEFAULT_DENY_GUIDANCE = 'Do not attempt to work around this denial. '
208
+ + 'Choose a materially safer alternative that stays inside your permissions, or ask the user before retrying this action.'
209
+
210
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
211
+ export const Config: z<Config> = z.object({
212
+ enableByDefault: z.boolean().default(true),
213
+ toolsPolicy: z.object({
214
+ default: POLICY.default('human'),
215
+ overrides: z.dict(POLICY).default({}),
216
+ }).default({ default: 'human', overrides: {} }),
217
+ riskRules: z.array(z.object({
218
+ pattern: z.string(),
219
+ policy: POLICY,
220
+ field: FIELD.default('reason'),
221
+ })).default([]),
222
+ reviewerProvider: z.string().default('fork'),
223
+ reviewerModel: z.string(),
224
+ reviewerTimeoutMs: z.number().default(60_000),
225
+ reviewerTools: z.array(z.string()).default(['read', 'glob', 'grep']),
226
+ fallbackPolicy: FALLBACK.default('rejected'),
227
+ maxReviewsPerTurn: z.number().default(10),
228
+ maxFailuresPerTurn: z.number(),
229
+ reasonMaxChars: z.number().default(2000),
230
+ reviewerGuidance: z.string(),
231
+ reviewerPolicyText: z.string(),
232
+ denyGuidance: z.string().default(DEFAULT_DENY_GUIDANCE),
233
+ contextBudget: z.object({
234
+ turns: z.number().default(0),
235
+ maxChars: z.number().default(4000),
236
+ }).default({ turns: 0, maxChars: 4000 }),
237
+ riskPolicy: z.object({
238
+ maxAutoAllow: RISK.default('high'),
239
+ onHighRisk: z.union(['delegate', 'deny'] as const).default('delegate'),
240
+ }).default({ maxAutoAllow: 'high', onHighRisk: 'delegate' }),
241
+ circuitBreaker: z.object({
242
+ consecutiveDenies: z.number().default(3),
243
+ // The window defaults must stay reachable under the default
244
+ // maxReviewsPerTurn (10): a window trip needs 6 denies inside the turn's
245
+ // at most 10 verdicts, so interleaved denials trip it even when no
246
+ // 3-deny run occurs (a 10-in-50 default could never trip first).
247
+ windowDenies: z.number().default(6),
248
+ windowSize: z.number().default(10),
249
+ action: CIRCUIT_ACTION.default('delegate'),
250
+ }).default({ consecutiveDenies: 3, windowDenies: 6, windowSize: 10, action: 'delegate' }),
251
+ overrideTtlMs: z.number().default(5 * 60_000),
252
+ language: z.union(['en', 'zh'] as const).default('en'),
253
+ })
254
+
255
+ /** Whether `level` ranks strictly above `cap` in {@link RISK_ORDER}. */
256
+ export function riskExceeds(level: RiskLevel, cap: RiskLevel): boolean {
257
+ return RISK_ORDER.indexOf(level) > RISK_ORDER.indexOf(cap)
258
+ }
259
+
260
+ /**
261
+ * Validate raw values and compile the resolved config. Defaults are applied
262
+ * HERE — the explicit resolution step — so a partially-specified config from
263
+ * a direct `ctx.plugin` mount behaves like the loader-filled one.
264
+ * @param config - raw (possibly partial) plugin config.
265
+ * @returns the fully resolved config.
266
+ * @throws TypeError on invalid risk-rule regexes, non-positive budgets, or
267
+ * out-of-range numbers (misconfiguration fails loud).
268
+ */
269
+ export function resolveConfig(config: Config = {}): ResolvedConfig {
270
+ if (!Number.isSafeInteger(config.reviewerTimeoutMs ?? 60_000) || (config.reviewerTimeoutMs ?? 60_000) <= 0) {
271
+ throw new TypeError(`reviewerTimeoutMs must be a positive safe integer, got ${String(config.reviewerTimeoutMs)}`)
272
+ }
273
+ if (!Number.isSafeInteger(config.maxReviewsPerTurn ?? 10) || (config.maxReviewsPerTurn ?? 10) <= 0) {
274
+ throw new TypeError(`maxReviewsPerTurn must be a positive safe integer, got ${String(config.maxReviewsPerTurn)}`)
275
+ }
276
+ if (!Number.isSafeInteger(config.maxFailuresPerTurn ?? 10) || (config.maxFailuresPerTurn ?? 10) <= 0) {
277
+ throw new TypeError(`maxFailuresPerTurn must be a positive safe integer, got ${String(config.maxFailuresPerTurn)}`)
278
+ }
279
+ if (!Number.isSafeInteger(config.reasonMaxChars ?? 2000) || (config.reasonMaxChars ?? 2000) <= 0) {
280
+ throw new TypeError(`reasonMaxChars must be a positive safe integer, got ${String(config.reasonMaxChars)}`)
281
+ }
282
+ const turns = config.contextBudget?.turns ?? 0
283
+ if (!Number.isSafeInteger(turns) || turns < 0) {
284
+ throw new TypeError(`contextBudget.turns must be a non-negative safe integer, got ${String(turns)}`)
285
+ }
286
+ const contextChars = config.contextBudget?.maxChars ?? 4000
287
+ if (!Number.isSafeInteger(contextChars) || contextChars <= 0) {
288
+ throw new TypeError(`contextBudget.maxChars must be a positive safe integer, got ${String(contextChars)}`)
289
+ }
290
+ const breaker = config.circuitBreaker ?? {}
291
+ for (const [key, value] of [['consecutiveDenies', breaker.consecutiveDenies ?? 3], ['windowDenies', breaker.windowDenies ?? 6], ['windowSize', breaker.windowSize ?? 10]] as const) {
292
+ if (!Number.isSafeInteger(value) || value <= 0) {
293
+ throw new TypeError(`circuitBreaker.${key} must be a positive safe integer, got ${String(value)}`)
294
+ }
295
+ }
296
+ if (!Number.isSafeInteger(config.overrideTtlMs ?? 5 * 60_000) || (config.overrideTtlMs ?? 5 * 60_000) <= 0) {
297
+ throw new TypeError(`overrideTtlMs must be a positive safe integer, got ${String(config.overrideTtlMs)}`)
298
+ }
299
+ const reviewerTools = config.reviewerTools ?? ['read', 'glob', 'grep']
300
+ if (reviewerTools.length === 0) {
301
+ throw new TypeError('reviewerTools must name at least one tool: an empty allow-list would leave the reviewer with no tool face')
302
+ }
303
+ const riskRules = (config.riskRules ?? []).map((rule): ResolvedRiskRule => {
304
+ let regex: RegExp
305
+ try {
306
+ // Flags are stripped: a global flag would make repeated .test() calls
307
+ // stateful (lastIndex), and other flags would silently change semantics.
308
+ regex = new RegExp(rule.pattern, 'u')
309
+ } catch (error: unknown) {
310
+ throw new TypeError(`risk rule pattern ${JSON.stringify(rule.pattern)} is not a valid regular expression: ${String(error)}`)
311
+ }
312
+ return { pattern: rule.pattern, regex, policy: rule.policy, field: rule.field ?? 'reason' }
313
+ })
314
+ return {
315
+ enableByDefault: config.enableByDefault ?? true,
316
+ toolsPolicy: {
317
+ default: config.toolsPolicy?.default ?? 'human',
318
+ overrides: config.toolsPolicy?.overrides ?? {},
319
+ },
320
+ riskRules,
321
+ reviewerProvider: config.reviewerProvider ?? 'fork',
322
+ reviewerModel: config.reviewerModel,
323
+ reviewerTimeoutMs: config.reviewerTimeoutMs ?? 60_000,
324
+ reviewerTools,
325
+ fallbackPolicy: config.fallbackPolicy ?? 'rejected',
326
+ maxReviewsPerTurn: config.maxReviewsPerTurn ?? 10,
327
+ maxFailuresPerTurn: config.maxFailuresPerTurn ?? config.maxReviewsPerTurn ?? 10,
328
+ reasonMaxChars: config.reasonMaxChars ?? 2000,
329
+ reviewerGuidance: config.reviewerGuidance,
330
+ reviewerPolicyText: config.reviewerPolicyText,
331
+ denyGuidance: config.denyGuidance ?? DEFAULT_DENY_GUIDANCE,
332
+ contextBudget: { turns, maxChars: contextChars },
333
+ riskPolicy: {
334
+ maxAutoAllow: config.riskPolicy?.maxAutoAllow ?? 'high',
335
+ onHighRisk: config.riskPolicy?.onHighRisk ?? 'delegate',
336
+ },
337
+ circuitBreaker: {
338
+ consecutiveDenies: breaker.consecutiveDenies ?? 3,
339
+ windowDenies: breaker.windowDenies ?? 6,
340
+ windowSize: breaker.windowSize ?? 10,
341
+ action: breaker.action ?? 'delegate',
342
+ },
343
+ overrideTtlMs: config.overrideTtlMs ?? 5 * 60_000,
344
+ language: config.language ?? 'en',
345
+ }
346
+ }