@planningo/duul 1.0.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +438 -0
  3. package/README.md +463 -0
  4. package/build/index.d.ts +2 -0
  5. package/build/index.js +18 -0
  6. package/build/prompts/code-review-system.d.ts +9 -0
  7. package/build/prompts/code-review-system.js +116 -0
  8. package/build/prompts/execution-partition-system.d.ts +11 -0
  9. package/build/prompts/execution-partition-system.js +76 -0
  10. package/build/prompts/plan-review-system.d.ts +29 -0
  11. package/build/prompts/plan-review-system.js +175 -0
  12. package/build/schemas/code-review.d.ts +514 -0
  13. package/build/schemas/code-review.js +175 -0
  14. package/build/schemas/common.d.ts +118 -0
  15. package/build/schemas/common.js +64 -0
  16. package/build/schemas/execution-partition.d.ts +597 -0
  17. package/build/schemas/execution-partition.js +107 -0
  18. package/build/schemas/plan-review.d.ts +523 -0
  19. package/build/schemas/plan-review.js +175 -0
  20. package/build/services/filesystem-tools.d.ts +6 -0
  21. package/build/services/filesystem-tools.js +39 -0
  22. package/build/services/filesystem.d.ts +69 -0
  23. package/build/services/filesystem.js +609 -0
  24. package/build/services/pricing.d.ts +8 -0
  25. package/build/services/pricing.js +105 -0
  26. package/build/services/providers/anthropic.d.ts +28 -0
  27. package/build/services/providers/anthropic.js +431 -0
  28. package/build/services/providers/google.d.ts +28 -0
  29. package/build/services/providers/google.js +358 -0
  30. package/build/services/providers/openai.d.ts +22 -0
  31. package/build/services/providers/openai.js +395 -0
  32. package/build/services/providers/types.d.ts +82 -0
  33. package/build/services/providers/types.js +1 -0
  34. package/build/services/review-gates.d.ts +83 -0
  35. package/build/services/review-gates.js +200 -0
  36. package/build/services/review-limits.d.ts +36 -0
  37. package/build/services/review-limits.js +65 -0
  38. package/build/services/reviewer.d.ts +30 -0
  39. package/build/services/reviewer.js +243 -0
  40. package/build/services/usage-logger.d.ts +2 -0
  41. package/build/services/usage-logger.js +42 -0
  42. package/build/tools/code-review.d.ts +2 -0
  43. package/build/tools/code-review.js +178 -0
  44. package/build/tools/execution-partition.d.ts +2 -0
  45. package/build/tools/execution-partition.js +146 -0
  46. package/build/tools/plan-review.d.ts +2 -0
  47. package/build/tools/plan-review.js +183 -0
  48. package/package.json +65 -0
@@ -0,0 +1,395 @@
1
+ import OpenAI from 'openai';
2
+ import { zodTextFormat } from 'openai/helpers/zod';
3
+ import { validateProjectRoot } from '../filesystem.js';
4
+ import { executeFilesystemTool } from '../filesystem-tools.js';
5
+ import { estimateCost } from '../pricing.js';
6
+ const MAX_INPUT_CHARS = 400_000;
7
+ const MAX_TOOL_ROUNDS = 10;
8
+ const MAX_RETRIES = 3;
9
+ const MAX_REPEAT_CALLS = 3;
10
+ const LINKED_PATH_HINT = ' To access a linked root, prefix with "linked:<index>:<path>" (e.g. "linked:0:src/types.ts").';
11
+ const FILESYSTEM_TOOLS = [
12
+ {
13
+ type: 'function',
14
+ name: 'read_file',
15
+ description: 'Read a file from the project being reviewed. Use this to examine type definitions, ' +
16
+ 'data models, interfaces, utility functions, and any code you need for thorough review context. ' +
17
+ 'Returns file contents as text.' + LINKED_PATH_HINT,
18
+ parameters: {
19
+ type: 'object',
20
+ properties: {
21
+ path: {
22
+ type: 'string',
23
+ description: 'Relative path from project root (e.g. "src/types.ts"), or "linked:<index>:<path>" for linked roots',
24
+ },
25
+ },
26
+ required: ['path'],
27
+ additionalProperties: false,
28
+ },
29
+ strict: true,
30
+ },
31
+ {
32
+ type: 'function',
33
+ name: 'list_directory',
34
+ description: 'List files and directories at a path in the project. Use this to understand project structure.' + LINKED_PATH_HINT,
35
+ parameters: {
36
+ type: 'object',
37
+ properties: {
38
+ path: {
39
+ type: 'string',
40
+ description: 'Relative path from project root. Use "." for project root, or "linked:<index>:<path>" for linked roots.',
41
+ },
42
+ },
43
+ required: ['path'],
44
+ additionalProperties: false,
45
+ },
46
+ strict: true,
47
+ },
48
+ {
49
+ type: 'function',
50
+ name: 'search_in_files',
51
+ description: 'Search for a pattern across project files. Uses ripgrep when available, falls back to git grep. ' +
52
+ 'PREFER this over read_file when you need to find specific symbols, keywords, or patterns. ' +
53
+ 'Returns matching lines with file paths and line numbers. ' +
54
+ 'Search is restricted to allowed working directories when configured.',
55
+ parameters: {
56
+ type: 'object',
57
+ properties: {
58
+ query: { type: 'string', description: 'Search pattern (literal string or regex)' },
59
+ paths: { type: ['array', 'null'], items: { type: 'string' }, description: 'Optional: restrict search to these relative paths/directories' },
60
+ glob: { type: ['string', 'null'], description: 'Optional: glob pattern to filter files (e.g. "*.ts", "src/**/*.js")' },
61
+ },
62
+ required: ['query', 'paths', 'glob'],
63
+ additionalProperties: false,
64
+ },
65
+ strict: true,
66
+ },
67
+ {
68
+ type: 'function',
69
+ name: 'read_file_range',
70
+ description: 'Read a specific line range from a file. PREFER this over read_file for large files. Returns numbered lines. Max 200 lines per call.' + LINKED_PATH_HINT,
71
+ parameters: {
72
+ type: 'object',
73
+ properties: {
74
+ path: { type: 'string', description: 'Relative path from project root, or "linked:<index>:<path>" for linked roots' },
75
+ start_line: { type: 'number', description: 'First line to read (1-based)' },
76
+ end_line: { type: 'number', description: 'Last line to read (1-based, inclusive)' },
77
+ },
78
+ required: ['path', 'start_line', 'end_line'],
79
+ additionalProperties: false,
80
+ },
81
+ strict: true,
82
+ },
83
+ {
84
+ type: 'function',
85
+ name: 'stat_file',
86
+ description: 'Get file metadata: size, type, and modification time. Use this before read_file to check if a file is too large.' + LINKED_PATH_HINT,
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ path: { type: 'string', description: 'Relative path from project root, or "linked:<index>:<path>" for linked roots' },
91
+ },
92
+ required: ['path'],
93
+ additionalProperties: false,
94
+ },
95
+ strict: true,
96
+ },
97
+ {
98
+ type: 'function',
99
+ name: 'read_json',
100
+ description: 'Read a JSON file, optionally extracting a value at a JSON pointer path. Use this for package.json, tsconfig.json, etc.' + LINKED_PATH_HINT,
101
+ parameters: {
102
+ type: 'object',
103
+ properties: {
104
+ path: { type: 'string', description: 'Relative path to a JSON file, or "linked:<index>:<path>" for linked roots' },
105
+ json_pointer: { type: ['string', 'null'], description: 'Optional: JSON pointer (e.g. "/dependencies", "/scripts/build")' },
106
+ },
107
+ required: ['path', 'json_pointer'],
108
+ additionalProperties: false,
109
+ },
110
+ strict: true,
111
+ },
112
+ {
113
+ type: 'function',
114
+ name: 'list_tracked_files',
115
+ description: 'List git-tracked files, optionally filtered by a directory prefix. Only shows files committed to git.',
116
+ parameters: {
117
+ type: 'object',
118
+ properties: {
119
+ prefix: { type: ['string', 'null'], description: 'Optional: directory prefix to filter (e.g. "src/")' },
120
+ },
121
+ required: ['prefix'],
122
+ additionalProperties: false,
123
+ },
124
+ strict: true,
125
+ },
126
+ {
127
+ type: 'function',
128
+ name: 'get_git_diff',
129
+ description: 'Get git diff output for the workspace. Use this to see exactly what changed — ' +
130
+ 'PREFER this over reading full files when reviewing modifications. ' +
131
+ 'Returns unified diff format. Defaults to comparing against HEAD (current workspace changes). Also includes untracked new files.',
132
+ parameters: {
133
+ type: 'object',
134
+ properties: {
135
+ base: { type: ['string', 'null'], description: 'Base ref to diff against (e.g. "HEAD~1", "main", a commit SHA). Defaults to HEAD.' },
136
+ paths: { type: ['array', 'null'], items: { type: 'string' }, description: 'Optional: restrict diff to these relative paths' },
137
+ },
138
+ required: ['base', 'paths'],
139
+ additionalProperties: false,
140
+ },
141
+ strict: true,
142
+ },
143
+ ];
144
+ // Guard: strict: true requires every property key to be in required.
145
+ // Catches the mistake at module load time, not at OpenAI call time.
146
+ for (const tool of FILESYSTEM_TOOLS) {
147
+ if (!('strict' in tool) || !tool.strict)
148
+ continue;
149
+ const props = Object.keys(tool.parameters.properties);
150
+ const req = new Set(tool.parameters.required);
151
+ const missing = props.filter((k) => !req.has(k));
152
+ if (missing.length > 0) {
153
+ throw new Error(`[duul] Tool "${tool.name}" has strict: true but properties [${missing.join(', ')}] are not in required. ` +
154
+ `Use type union with null for optional params.`);
155
+ }
156
+ }
157
+ function validateInputLength(systemPrompt, userMessage) {
158
+ const totalChars = systemPrompt.length + userMessage.length;
159
+ if (totalChars > MAX_INPUT_CHARS) {
160
+ const estimatedTokens = Math.ceil(totalChars / 4);
161
+ const maxTokens = Math.ceil(MAX_INPUT_CHARS / 4);
162
+ throw new Error(`Input too large (~${estimatedTokens} estimated tokens, max ~${maxTokens}). ` +
163
+ `Total input: ${totalChars} chars (system: ${systemPrompt.length}, user: ${userMessage.length}). ` +
164
+ `Reduce the size of your input fields — try trimming file_tree, plan, or code content.`);
165
+ }
166
+ }
167
+ export class OpenAIProvider {
168
+ name = 'openai';
169
+ capabilities = {
170
+ structuredOutputs: true,
171
+ toolCalling: true,
172
+ previousResponseId: true,
173
+ jsonSchemaStrict: true,
174
+ };
175
+ client;
176
+ model;
177
+ temperature;
178
+ topP;
179
+ constructor(config) {
180
+ const apiKey = config?.apiKey ?? process.env.OPENAI_API_KEY;
181
+ if (!apiKey) {
182
+ throw new Error('OPENAI_API_KEY environment variable is not set');
183
+ }
184
+ this.client = new OpenAI({
185
+ apiKey,
186
+ ...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
187
+ });
188
+ this.model = config?.model ?? process.env.REVIEW_MODEL ?? 'gpt-5.4';
189
+ this.temperature = config?.temperature ?? 0.2;
190
+ this.topP = config?.topP ?? 0.1;
191
+ }
192
+ async review(options) {
193
+ const { systemPrompt, userMessage, schemaName, outputSchema, workspaceScope, previousReviewId } = options;
194
+ validateInputLength(systemPrompt, userMessage);
195
+ const effectiveRoot = workspaceScope?.root ?? null;
196
+ if (effectiveRoot && !workspaceScope) {
197
+ validateProjectRoot(effectiveRoot);
198
+ }
199
+ const tools = effectiveRoot ? FILESYSTEM_TOOLS : undefined;
200
+ let allUsedTools = [];
201
+ // Accumulate token usage across all API calls
202
+ let totalInputTokens = 0;
203
+ let totalOutputTokens = 0;
204
+ let totalCachedInputTokens = 0;
205
+ let apiCallCount = 0;
206
+ const accumulateUsage = (response) => {
207
+ apiCallCount++;
208
+ const u = response.usage;
209
+ if (u) {
210
+ totalInputTokens += u.input_tokens ?? 0;
211
+ totalOutputTokens += u.output_tokens ?? 0;
212
+ totalCachedInputTokens += u.input_tokens_details?.cached_tokens ?? 0;
213
+ }
214
+ };
215
+ const buildUsage = () => ({
216
+ input_tokens: totalInputTokens,
217
+ output_tokens: totalOutputTokens,
218
+ total_tokens: totalInputTokens + totalOutputTokens,
219
+ api_calls: apiCallCount,
220
+ provider: 'openai',
221
+ model: this.model,
222
+ estimated_cost_usd: estimateCost(this.model, totalInputTokens, totalOutputTokens, totalCachedInputTokens),
223
+ ...(totalCachedInputTokens > 0 ? { cached_input_tokens: totalCachedInputTokens } : {}),
224
+ });
225
+ const baseParams = {
226
+ model: this.model,
227
+ instructions: systemPrompt,
228
+ temperature: this.temperature,
229
+ top_p: this.topP,
230
+ max_output_tokens: 16384,
231
+ text: { format: zodTextFormat(outputSchema, schemaName) },
232
+ ...(tools ? { tools } : {}),
233
+ };
234
+ let response = await this.apiCallWithRetry({
235
+ ...baseParams,
236
+ input: [{ role: 'user', content: [{ type: 'input_text', text: userMessage }] }],
237
+ ...(previousReviewId ? { previous_response_id: previousReviewId } : {}),
238
+ });
239
+ accumulateUsage(response);
240
+ console.error(`[duul] response.id=${response.id} model=${this.model} provider=openai`);
241
+ // Agentic tool-calling loop
242
+ if (effectiveRoot) {
243
+ const toolReadBudget = MAX_INPUT_CHARS - (systemPrompt.length + userMessage.length);
244
+ let accumulatedToolChars = 0;
245
+ const FULL_READ_TOOLS = new Set(['read_file', 'get_git_diff']);
246
+ const SEARCH_ONLY_TOOLS = new Set(['search_in_files', 'list_tracked_files', 'stat_file']);
247
+ const getStrategyLevel = () => {
248
+ const ratio = accumulatedToolChars / toolReadBudget;
249
+ if (ratio < 0.5)
250
+ return 0;
251
+ if (ratio < 0.8)
252
+ return 1;
253
+ if (ratio < 1.0)
254
+ return 2;
255
+ return 3;
256
+ };
257
+ const isToolAllowed = (toolName, level) => {
258
+ if (level >= 3)
259
+ return false;
260
+ if (level >= 2)
261
+ return SEARCH_ONLY_TOOLS.has(toolName);
262
+ if (level >= 1)
263
+ return !FULL_READ_TOOLS.has(toolName);
264
+ return true;
265
+ };
266
+ const budgetMessage = (toolName, level) => {
267
+ if (level >= 3)
268
+ return 'Budget exhausted. You must produce your final review verdict now with the context you already have.';
269
+ if (level >= 2)
270
+ return `[Budget Level 2] Only search/stat tools allowed. "${toolName}" blocked.`;
271
+ if (level >= 1)
272
+ return `[Budget Level 1] Full file reads blocked. "${toolName}" not allowed. Use read_file_range or search_in_files.`;
273
+ return '';
274
+ };
275
+ const toolCache = new Map();
276
+ const callCounts = new Map();
277
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
278
+ const functionCalls = this.getFunctionCalls(response);
279
+ if (functionCalls.length === 0)
280
+ break;
281
+ const strategyLevel = getStrategyLevel();
282
+ console.error(`[duul] Tool round ${round + 1}: ${functionCalls.length} call(s), budget ${accumulatedToolChars}/${toolReadBudget} (level ${strategyLevel})`);
283
+ const toolResults = [];
284
+ for (const call of functionCalls) {
285
+ if (call.type !== 'function_call')
286
+ continue;
287
+ const args = JSON.parse(call.arguments);
288
+ const cacheKey = `${call.name}:${call.arguments}`;
289
+ const argSummary = args.path ?? args.query ?? args.prefix ?? '';
290
+ const count = (callCounts.get(cacheKey) ?? 0) + 1;
291
+ callCounts.set(cacheKey, count);
292
+ if (count > MAX_REPEAT_CALLS) {
293
+ toolResults.push({ type: 'function_call_output', call_id: call.call_id, output: 'You have already read this content multiple times. Use the context you already have to complete your review.' });
294
+ continue;
295
+ }
296
+ if (toolCache.has(cacheKey)) {
297
+ toolResults.push({ type: 'function_call_output', call_id: call.call_id, output: toolCache.get(cacheKey) });
298
+ continue;
299
+ }
300
+ const currentLevel = getStrategyLevel();
301
+ if (!isToolAllowed(call.name, currentLevel)) {
302
+ toolResults.push({ type: 'function_call_output', call_id: call.call_id, output: budgetMessage(call.name, currentLevel) });
303
+ continue;
304
+ }
305
+ const result = await executeFilesystemTool(effectiveRoot, call.name, args, workspaceScope);
306
+ toolCache.set(cacheKey, result);
307
+ allUsedTools.push(`${call.name}(${argSummary})`);
308
+ accumulatedToolChars += result.length;
309
+ console.error(`[duul] ${call.name}(${argSummary}) -> ${result.length} chars (total: ${accumulatedToolChars}/${toolReadBudget}, level ${getStrategyLevel()})`);
310
+ toolResults.push({ type: 'function_call_output', call_id: call.call_id, output: result });
311
+ }
312
+ response = await this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: toolResults });
313
+ accumulateUsage(response);
314
+ console.error(`[duul] response.id=${response.id} (after tool round ${round + 1})`);
315
+ if (getStrategyLevel() >= 3 && this.hasPendingFunctionCalls(response)) {
316
+ const stopResults = this.getFunctionCalls(response).filter((c) => c.type === 'function_call').map((c) => ({
317
+ type: 'function_call_output', call_id: c.call_id,
318
+ output: 'No more file reads allowed. You must produce your final review verdict now.',
319
+ }));
320
+ response = await this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: stopResults });
321
+ accumulateUsage(response);
322
+ break;
323
+ }
324
+ if (getStrategyLevel() >= 3)
325
+ break;
326
+ }
327
+ if (this.hasPendingFunctionCalls(response)) {
328
+ const stopResults = this.getFunctionCalls(response).filter((c) => c.type === 'function_call').map((c) => ({
329
+ type: 'function_call_output', call_id: c.call_id,
330
+ output: 'Tool call limit reached. You must produce your final review verdict now.',
331
+ }));
332
+ response = await this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: stopResults });
333
+ accumulateUsage(response);
334
+ }
335
+ }
336
+ const usage = buildUsage();
337
+ const costStr = usage.estimated_cost_usd !== null ? ` (~$${usage.estimated_cost_usd.toFixed(4)})` : '';
338
+ const cachedStr = usage.cached_input_tokens ? ` [cached: ${usage.cached_input_tokens}]` : '';
339
+ console.error(`[duul] Token usage: ${usage.input_tokens} in + ${usage.output_tokens} out = ${usage.total_tokens} total (${usage.api_calls} API calls)${cachedStr}${costStr}`);
340
+ // Extract structured output
341
+ const parsed = this.extractStructuredOutput(response, outputSchema);
342
+ if (parsed !== null) {
343
+ return { parsed, reviewId: response.id, usage };
344
+ }
345
+ if (options.createFallback) {
346
+ const reason = this.hasPendingFunctionCalls(response) ? 'round_limit' : 'budget';
347
+ const fallback = options.createFallback(reason, allUsedTools);
348
+ console.error(`[duul] Returning structured fallback (reason: ${reason}).`);
349
+ return { parsed: fallback, reviewId: response.id, usage };
350
+ }
351
+ throw new Error('Review failed: could not obtain structured verdict after tool loop.');
352
+ }
353
+ async apiCallWithRetry(params) {
354
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
355
+ const controller = new AbortController();
356
+ const timeout = setTimeout(() => controller.abort(), 120_000);
357
+ try {
358
+ const response = await this.client.responses.create({ ...params, stream: false }, { signal: controller.signal });
359
+ clearTimeout(timeout);
360
+ return response;
361
+ }
362
+ catch (error) {
363
+ clearTimeout(timeout);
364
+ const isRetryable = error instanceof Error && ('status' in error ? (error.status === 429 || error.status >= 500) : error.name === 'AbortError');
365
+ if (isRetryable && attempt < MAX_RETRIES - 1) {
366
+ const delay = 1000 * Math.pow(2, attempt);
367
+ console.error(`[duul] Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms`);
368
+ await new Promise((resolve) => setTimeout(resolve, delay));
369
+ continue;
370
+ }
371
+ throw error;
372
+ }
373
+ }
374
+ throw new Error('Unreachable: exhausted retries');
375
+ }
376
+ extractStructuredOutput(response, outputSchema) {
377
+ for (const item of response.output) {
378
+ if (item.type === 'message' && 'content' in item) {
379
+ const msg = item;
380
+ for (const content of msg.content) {
381
+ if (content.type === 'output_text' && content.text) {
382
+ return outputSchema.parse(JSON.parse(content.text));
383
+ }
384
+ }
385
+ }
386
+ }
387
+ return null;
388
+ }
389
+ hasPendingFunctionCalls(response) {
390
+ return response.output.some((item) => item.type === 'function_call');
391
+ }
392
+ getFunctionCalls(response) {
393
+ return response.output.filter((item) => item.type === 'function_call');
394
+ }
395
+ }
@@ -0,0 +1,82 @@
1
+ import type { z } from 'zod';
2
+ import type { WorkspaceScope } from '../filesystem.js';
3
+ export type ExhaustionReason = 'budget' | 'repeat' | 'round_limit';
4
+ /**
5
+ * Token usage from a single review call.
6
+ * Accumulated across all API calls (including tool loop rounds).
7
+ */
8
+ export interface TokenUsage {
9
+ input_tokens: number;
10
+ output_tokens: number;
11
+ total_tokens: number;
12
+ /** Number of API calls made (1 + tool loop rounds) */
13
+ api_calls: number;
14
+ provider: string;
15
+ model: string;
16
+ /** Estimated cost in USD (null if pricing unknown for this model) */
17
+ estimated_cost_usd: number | null;
18
+ /** Input tokens served from provider prompt cache (billed at ~0.1× input). */
19
+ cached_input_tokens?: number;
20
+ /** Input tokens written to the provider prompt cache (Anthropic only; billed at ~1.25× input). */
21
+ cache_creation_input_tokens?: number;
22
+ }
23
+ /**
24
+ * A single conversation turn, used to simulate previous_response_id
25
+ * for providers that don't support it natively (Anthropic, Google).
26
+ */
27
+ export interface ConversationTurn {
28
+ role: 'user' | 'assistant';
29
+ content: unknown;
30
+ }
31
+ export interface ReviewCallOptions<T extends z.ZodType> {
32
+ systemPrompt: string;
33
+ userMessage: string;
34
+ schemaName: string;
35
+ outputSchema: T;
36
+ workspaceScope?: WorkspaceScope | null;
37
+ previousReviewId?: string;
38
+ /** Conversation history from previous rounds (for providers without native context persistence) */
39
+ conversationHistory?: ConversationTurn[];
40
+ /** Factory to create a structured fallback when the tool loop is exhausted */
41
+ createFallback?: (reason: ExhaustionReason, usedTools: string[]) => z.infer<T>;
42
+ }
43
+ export interface ReviewCallResult<T> {
44
+ parsed: T;
45
+ reviewId: string;
46
+ usage: TokenUsage;
47
+ /** Conversation turns from this review (for storage/replay in subsequent rounds) */
48
+ conversationTurns?: ConversationTurn[];
49
+ }
50
+ /**
51
+ * Describes what a provider supports.
52
+ * Used for capability-aware degradation.
53
+ */
54
+ export interface ProviderCapabilities {
55
+ /** Supports structured output (JSON schema enforcement) */
56
+ structuredOutputs: boolean;
57
+ /** Supports tool/function calling */
58
+ toolCalling: boolean;
59
+ /** Supports previous_response_id for conversation continuity */
60
+ previousResponseId: boolean;
61
+ /** Supports strict JSON schema mode */
62
+ jsonSchemaStrict: boolean;
63
+ }
64
+ /**
65
+ * Common interface for all reviewer providers.
66
+ */
67
+ export interface ReviewerProvider {
68
+ readonly name: string;
69
+ readonly capabilities: ProviderCapabilities;
70
+ review<T extends z.ZodType>(options: ReviewCallOptions<T>): Promise<ReviewCallResult<z.infer<T>>>;
71
+ }
72
+ /**
73
+ * Configuration for creating a provider instance.
74
+ */
75
+ export interface ProviderConfig {
76
+ provider: 'openai' | 'anthropic' | 'google' | 'openrouter' | 'compatible';
77
+ model?: string;
78
+ apiKey?: string;
79
+ baseUrl?: string;
80
+ temperature?: number;
81
+ topP?: number;
82
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Post-LLM review gates.
3
+ *
4
+ * Each detector returns zero or more GateResult objects. The applyGates
5
+ * orchestrator merges them into a single delta that the tool handler can
6
+ * fold into the reviewer's response.
7
+ *
8
+ * Motivation: empirical analysis of DUUL sessions showed that the reviewer
9
+ * reliably APPROVES plans/code that are internally consistent but fail to
10
+ * address the user's reported symptom. These gates enforce the final
11
+ * symptom-match check that the model skips under pressure.
12
+ */
13
+ import type { ArtifactRef } from '../schemas/common.js';
14
+ export type GateSeverity = 'revise' | 'human';
15
+ export interface GateResult {
16
+ name: string;
17
+ severity: GateSeverity;
18
+ blocking_issue: {
19
+ description: string;
20
+ suggestion: string;
21
+ };
22
+ }
23
+ interface SymptomImpactShape {
24
+ before?: unknown;
25
+ after?: unknown;
26
+ causal_chain?: unknown;
27
+ }
28
+ /**
29
+ * Detect scope-punting language in caller notes.
30
+ * Weak signal: tier REVISE, not HUMAN, because legitimate out-of-scope
31
+ * notes do exist (e.g. "out of scope — tracked as TICKET-123").
32
+ */
33
+ export declare function detectScopePunting(notes?: string | null): GateResult[];
34
+ /**
35
+ * Detect changes that only touch test files.
36
+ * Hard tier HUMAN: real bug fixes almost always touch non-test code.
37
+ * Skipped when userOriginalRequest explicitly asks for tests (coverage PRs).
38
+ */
39
+ export declare function detectTestOnlyChanges(changedFiles?: string[] | null, gitDiff?: string | null, userOriginalRequest?: string | null): GateResult[];
40
+ /**
41
+ * Detect caller-pre-diagnosed handoff: short user request + very long
42
+ * caller notes. The caller may have rewritten the problem in a way that
43
+ * anchors the reviewer on an incorrect diagnosis.
44
+ */
45
+ export declare function detectDiagnosisHandoff(userOriginalRequest?: string | null, notes?: string | null): GateResult[];
46
+ /**
47
+ * Detect rendering/UI symptoms when the change does not plausibly touch
48
+ * rendering code AND the reviewer has not articulated a causal chain.
49
+ *
50
+ * Suppressed when any of the following is true:
51
+ * - An `artifact_refs` entry has an image path (screenshot documents the bug).
52
+ * - A rendering-adjacent path shows up in `changedFiles` OR in `gitDiff`.
53
+ * - The reviewer filled `symptom_impact` (before/after/causal_chain all non-empty),
54
+ * committing on paper to how the change produces the visual effect. This lets
55
+ * backend-only fixes (e.g. "chart is empty" → API fix) pass without a false trip.
56
+ */
57
+ export declare function detectRenderingSymptom(userOriginalRequest?: string | null, artifactRefs?: ArtifactRef[] | null, changedFiles?: string[] | null, gitDiff?: string | null, symptomImpact?: SymptomImpactShape | null): GateResult[];
58
+ /**
59
+ * Enforce that the reviewer filled symptom_impact when user_original_request
60
+ * was supplied. Tier REVISE — the reviewer should self-correct on the
61
+ * next round.
62
+ */
63
+ export declare function enforceSymptomImpact(userOriginalRequest?: string | null, symptomImpact?: SymptomImpactShape | null): GateResult[];
64
+ export interface ApplyGatesArgs {
65
+ phase: 'plan' | 'code';
66
+ userOriginalRequest?: string | null;
67
+ notesToReviewer?: string | null;
68
+ changedFiles?: string[] | null;
69
+ gitDiff?: string | null;
70
+ artifactRefs?: ArtifactRef[] | null;
71
+ symptomImpact?: SymptomImpactShape | null;
72
+ }
73
+ export interface ApplyGatesResult {
74
+ extraBlockingIssues: Array<{
75
+ description: string;
76
+ suggestion: string;
77
+ }>;
78
+ forcedVerdict?: 'REVISE';
79
+ forcedHumanReview?: boolean;
80
+ tripped: string[];
81
+ }
82
+ export declare function applyGates(args: ApplyGatesArgs): ApplyGatesResult;
83
+ export {};