@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,609 @@
1
+ import { readFile, readdir, lstat, realpath } from 'fs/promises';
2
+ import { execFile } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import { resolve, relative, isAbsolute } from 'path';
5
+ const execFileAsync = promisify(execFile);
6
+ const MAX_FILE_SIZE = 100_000; // 100KB per file read
7
+ // Additional blocked paths/patterns beyond .env and node_modules
8
+ const BLOCKED_PATHS = ['.git', 'build', 'dist'];
9
+ const BLOCKED_EXTENSIONS = ['.log'];
10
+ /**
11
+ * Validates that project_root is a reasonable absolute path,
12
+ * not the filesystem root or a system directory.
13
+ */
14
+ export function validateProjectRoot(projectRoot) {
15
+ if (!isAbsolute(projectRoot)) {
16
+ throw new Error(`project_root must be an absolute path: ${projectRoot}`);
17
+ }
18
+ // Block overly broad roots
19
+ const blocked = ['/', '/etc', '/usr', '/var', '/tmp', '/bin', '/sbin', '/lib', '/opt'];
20
+ const homeDirs = ['/Users', '/home', '/root'];
21
+ if (blocked.includes(projectRoot)) {
22
+ throw new Error(`project_root too broad: ${projectRoot}`);
23
+ }
24
+ // Block bare home directories (allow subdirectories like /Users/foo/project)
25
+ if (homeDirs.includes(projectRoot)) {
26
+ throw new Error(`project_root too broad: ${projectRoot}`);
27
+ }
28
+ // Must be at least 3 levels deep (e.g., /Users/foo/project, not /Users/foo)
29
+ const segments = projectRoot.split('/').filter(Boolean);
30
+ if (segments.length < 3) {
31
+ throw new Error(`project_root too broad (must be at least 3 levels deep, e.g. /Users/foo/project): ${projectRoot}`);
32
+ }
33
+ }
34
+ /**
35
+ * Resolves requested path, validates it stays within project root
36
+ * (following symlinks with realpath), and blocks sensitive files.
37
+ */
38
+ async function safePath(projectRoot, requestedPath, workingDirectories) {
39
+ // Block absolute paths in requests
40
+ if (isAbsolute(requestedPath)) {
41
+ throw new Error(`Path must be relative to project root: ${requestedPath}`);
42
+ }
43
+ const resolved = resolve(projectRoot, requestedPath);
44
+ // Pre-check: logical path must stay within root
45
+ const rel = relative(projectRoot, resolved);
46
+ if (rel.startsWith('..') || resolve(projectRoot, rel) !== resolved) {
47
+ throw new Error(`Path outside project root: ${requestedPath}`);
48
+ }
49
+ // Working directories allowlist check
50
+ if (workingDirectories && workingDirectories.length > 0) {
51
+ const inAllowedDir = workingDirectories.some((dir) => {
52
+ const normalizedDir = dir.endsWith('/') ? dir : `${dir}/`;
53
+ return rel === dir || rel.startsWith(normalizedDir) || rel === '.';
54
+ });
55
+ if (!inAllowedDir && rel !== '.') {
56
+ throw new Error(`Path outside working directories: ${requestedPath}. ` +
57
+ `Allowed: ${workingDirectories.join(', ')}`);
58
+ }
59
+ }
60
+ // Post-check: real path (after resolving symlinks) must also stay within root
61
+ const realProjectRoot = await realpath(projectRoot);
62
+ let realResolved;
63
+ try {
64
+ realResolved = await realpath(resolved);
65
+ }
66
+ catch {
67
+ // File might not exist yet for stat, let it fail naturally later
68
+ realResolved = resolved;
69
+ }
70
+ const realRel = relative(realProjectRoot, realResolved);
71
+ if (realRel.startsWith('..')) {
72
+ throw new Error(`Symlink escape detected: ${requestedPath} resolves outside project root`);
73
+ }
74
+ // Block sensitive files
75
+ const lower = rel.toLowerCase();
76
+ if (lower.includes('.env') && !lower.endsWith('.example')) {
77
+ throw new Error(`Access denied (sensitive file): ${requestedPath}`);
78
+ }
79
+ if (rel === 'node_modules' || rel.startsWith('node_modules/') || rel.startsWith('node_modules\\')) {
80
+ throw new Error('Access denied: node_modules');
81
+ }
82
+ // Block additional paths (.git, build, dist)
83
+ const topSegment = rel.split('/')[0].split('\\')[0];
84
+ if (BLOCKED_PATHS.includes(topSegment)) {
85
+ throw new Error(`Access denied: ${topSegment}`);
86
+ }
87
+ // Block large file extensions (.log)
88
+ if (BLOCKED_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
89
+ throw new Error(`Access denied (blocked extension): ${requestedPath}`);
90
+ }
91
+ return realResolved;
92
+ }
93
+ /**
94
+ * Safe path resolution for linked roots (read-only external workspaces).
95
+ * Same as safePath but scoped to a linked root.
96
+ */
97
+ async function safeLinkedPath(linkedRoot, requestedPath) {
98
+ return safePath(linkedRoot, requestedPath);
99
+ }
100
+ /**
101
+ * List git-tracked files under a given prefix.
102
+ * Respects working_directories and supports linked roots via "linked:<index>:<prefix>" syntax.
103
+ */
104
+ export async function listTrackedFiles(root, prefix, scope) {
105
+ // Check for linked root prefix
106
+ if (prefix && scope) {
107
+ const parsed = parseLinkedPath(prefix, scope);
108
+ if (parsed) {
109
+ const idx = scope.linkedRoots.indexOf(parsed.root);
110
+ const files = await listTrackedFilesForRoot(parsed.root, parsed.relativePath || undefined);
111
+ return files.map((f) => `linked:${idx}:${f}`);
112
+ }
113
+ }
114
+ // Primary root
115
+ let files = await listTrackedFilesForRoot(root, prefix);
116
+ // If no explicit prefix and working_directories set, filter to those directories
117
+ if (!prefix && scope?.workingDirectories?.length) {
118
+ files = files.filter((f) => scope.workingDirectories.some((dir) => {
119
+ const normalizedDir = dir.endsWith('/') ? dir : `${dir}/`;
120
+ return f === dir || f.startsWith(normalizedDir);
121
+ }));
122
+ }
123
+ return files;
124
+ }
125
+ async function listTrackedFilesForRoot(root, prefix) {
126
+ const args = ['ls-files'];
127
+ if (prefix)
128
+ args.push(prefix);
129
+ try {
130
+ const { stdout } = await execFileAsync('git', args, { cwd: root, maxBuffer: 1024 * 1024 });
131
+ return stdout.trim().split('\n').filter(Boolean);
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ }
137
+ /**
138
+ * Check if a file is git-tracked.
139
+ */
140
+ export async function isTrackedFile(root, filePath) {
141
+ try {
142
+ await execFileAsync('git', ['ls-files', '--error-unmatch', filePath], { cwd: root });
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /**
150
+ * Resolves workspace scope from input fields following precedence rules:
151
+ * 1. workspace_root (preferred)
152
+ * 2. project_root (deprecated fallback)
153
+ * 3. working_directories (allowlist intersection)
154
+ * 4. linked_roots (separate read-only scopes)
155
+ */
156
+ export function resolveWorkspaceScope(input) {
157
+ const root = input.workspace_root ?? input.project_root;
158
+ if (!root)
159
+ return null;
160
+ if (input.workspace_root && input.project_root) {
161
+ console.error('[duul] Warning: both workspace_root and project_root provided. ' +
162
+ 'Using workspace_root. project_root is deprecated.');
163
+ }
164
+ else if (!input.workspace_root && input.project_root) {
165
+ console.error('[duul] Deprecation: project_root is deprecated, use workspace_root instead.');
166
+ }
167
+ validateProjectRoot(root);
168
+ // Validate linked_roots
169
+ const linkedRoots = [];
170
+ if (input.linked_roots) {
171
+ for (const lr of input.linked_roots) {
172
+ validateProjectRoot(lr);
173
+ linkedRoots.push(lr);
174
+ }
175
+ }
176
+ return {
177
+ root,
178
+ workingDirectories: input.working_directories ?? null,
179
+ linkedRoots,
180
+ trackedOnly: input.tracked_only ?? false,
181
+ };
182
+ }
183
+ /**
184
+ * Resolve a path that may target the primary root or a linked root.
185
+ * Format for linked root: "linked:<index>:<relative_path>" or just a plain relative path (primary root).
186
+ */
187
+ function parseLinkedPath(path, scope) {
188
+ if (!path.startsWith('linked:')) {
189
+ return null; // use primary root
190
+ }
191
+ if (!scope || scope.linkedRoots.length === 0) {
192
+ throw new Error('No linked roots configured. Use a relative path for the primary workspace.');
193
+ }
194
+ const parts = path.slice('linked:'.length);
195
+ const colonIdx = parts.indexOf(':');
196
+ if (colonIdx === -1) {
197
+ throw new Error(`Invalid linked path format: "${path}". Expected "linked:<index>:<relative_path>".`);
198
+ }
199
+ const indexStr = parts.slice(0, colonIdx);
200
+ const relativePath = parts.slice(colonIdx + 1);
201
+ const index = parseInt(indexStr, 10);
202
+ if (isNaN(index) || index < 0 || index >= scope.linkedRoots.length) {
203
+ throw new Error(`Invalid linked root index: ${indexStr}. Available: 0-${scope.linkedRoots.length - 1}.`);
204
+ }
205
+ return { root: scope.linkedRoots[index], relativePath, isLinked: true };
206
+ }
207
+ /**
208
+ * Guard: if trackedOnly is enabled, verify the file is git-tracked.
209
+ */
210
+ async function enforceTrackedOnly(root, filePath, trackedOnly) {
211
+ if (!trackedOnly)
212
+ return;
213
+ const tracked = await isTrackedFile(root, filePath);
214
+ if (!tracked) {
215
+ throw new Error(`Access denied: "${filePath}" is not a git-tracked file (tracked_only mode).`);
216
+ }
217
+ }
218
+ /**
219
+ * Resolve the effective root and path, handling linked roots, working directories, and tracked-only.
220
+ */
221
+ async function resolveToolPath(primaryRoot, requestedPath, scope) {
222
+ const linked = parseLinkedPath(requestedPath, scope);
223
+ if (linked) {
224
+ // Linked root: no working_directories restriction, but read-only (caller enforces)
225
+ const resolved = await safeLinkedPath(linked.root, linked.relativePath);
226
+ if (scope?.trackedOnly) {
227
+ await enforceTrackedOnly(linked.root, linked.relativePath, true);
228
+ }
229
+ return resolved;
230
+ }
231
+ // Primary root
232
+ const wdirs = scope?.workingDirectories ?? null;
233
+ const resolved = await safePath(primaryRoot, requestedPath, wdirs);
234
+ if (scope?.trackedOnly) {
235
+ await enforceTrackedOnly(primaryRoot, requestedPath, scope.trackedOnly);
236
+ }
237
+ return resolved;
238
+ }
239
+ export async function readProjectFile(projectRoot, filePath, scope) {
240
+ const resolved = await resolveToolPath(projectRoot, filePath, scope ?? null);
241
+ const stats = await lstat(resolved);
242
+ if (!stats.isFile()) {
243
+ throw new Error(`Not a file: ${filePath}`);
244
+ }
245
+ if (stats.size > MAX_FILE_SIZE) {
246
+ throw new Error(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE}). Try a more specific file.`);
247
+ }
248
+ return readFile(resolved, 'utf-8');
249
+ }
250
+ export async function listProjectDirectory(projectRoot, dirPath, scope) {
251
+ const resolved = await resolveToolPath(projectRoot, dirPath, scope ?? null);
252
+ const stats = await lstat(resolved);
253
+ if (!stats.isDirectory()) {
254
+ throw new Error(`Not a directory: ${dirPath}`);
255
+ }
256
+ const entries = await readdir(resolved, { withFileTypes: true });
257
+ return entries
258
+ .filter((e) => e.name !== 'node_modules' && !e.name.startsWith('.env'))
259
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
260
+ .sort()
261
+ .join('\n');
262
+ }
263
+ // --- Phase 2: New retrieval tools ---
264
+ const MAX_RANGE_LINES = 200;
265
+ const MAX_SEARCH_RESULTS = 50;
266
+ let _hasRipgrep = null;
267
+ async function hasRipgrep() {
268
+ if (_hasRipgrep !== null)
269
+ return _hasRipgrep;
270
+ try {
271
+ await execFileAsync('rg', ['--version']);
272
+ _hasRipgrep = true;
273
+ }
274
+ catch {
275
+ _hasRipgrep = false;
276
+ }
277
+ return _hasRipgrep;
278
+ }
279
+ /**
280
+ * Partition search paths into primary-root paths and linked-root paths.
281
+ * Validates all paths against their respective scope.
282
+ */
283
+ async function partitionSearchPaths(root, paths, scope, workingDirectories) {
284
+ const primary = [];
285
+ const linked = new Map();
286
+ for (const p of paths) {
287
+ const parsed = parseLinkedPath(p, scope);
288
+ if (parsed) {
289
+ // Validate the linked path
290
+ await safeLinkedPath(parsed.root, parsed.relativePath);
291
+ const idx = scope.linkedRoots.indexOf(parsed.root);
292
+ if (!linked.has(idx)) {
293
+ linked.set(idx, { root: parsed.root, paths: [] });
294
+ }
295
+ linked.get(idx).paths.push(parsed.relativePath);
296
+ }
297
+ else {
298
+ // Primary root path — validate against working directories
299
+ await safePath(root, p, workingDirectories);
300
+ primary.push(p);
301
+ }
302
+ }
303
+ return { primary, linked };
304
+ }
305
+ /**
306
+ * Run a search command on a single root directory.
307
+ */
308
+ async function runSearch(searchRoot, query, effectivePaths, glob, trackedOnly, maxLines) {
309
+ let backend;
310
+ let result;
311
+ try {
312
+ if (trackedOnly) {
313
+ backend = 'git_grep';
314
+ const args = ['grep', '-n', '--max-count', String(maxLines), '-e', query];
315
+ if (effectivePaths?.length)
316
+ args.push('--', ...effectivePaths);
317
+ const { stdout } = await execFileAsync('git', args, { cwd: searchRoot, maxBuffer: 512 * 1024 });
318
+ result = stdout;
319
+ }
320
+ else if (await hasRipgrep()) {
321
+ backend = 'rg';
322
+ const args = ['--no-heading', '-n', '--max-count', String(maxLines)];
323
+ if (glob)
324
+ args.push('--glob', glob);
325
+ args.push('--', query);
326
+ if (effectivePaths?.length)
327
+ args.push(...effectivePaths);
328
+ const { stdout } = await execFileAsync('rg', args, { cwd: searchRoot, maxBuffer: 512 * 1024 });
329
+ result = stdout;
330
+ }
331
+ else {
332
+ backend = 'git_grep';
333
+ const args = ['grep', '-n', '--max-count', String(maxLines), '-e', query];
334
+ if (effectivePaths?.length)
335
+ args.push('--', ...effectivePaths);
336
+ const { stdout } = await execFileAsync('git', args, { cwd: searchRoot, maxBuffer: 512 * 1024 });
337
+ result = stdout;
338
+ }
339
+ }
340
+ catch (error) {
341
+ const err = error;
342
+ if (err.code === 1 && (err.stdout === '' || err.stdout === undefined)) {
343
+ return { backend: backend, lines: [] };
344
+ }
345
+ if (err.stdout) {
346
+ return { backend: backend, lines: err.stdout.trim().split('\n') };
347
+ }
348
+ throw error;
349
+ }
350
+ return { backend: backend, lines: result.trim().split('\n').filter(Boolean) };
351
+ }
352
+ /**
353
+ * Search for a pattern in files using rg (preferred), git grep, or grep fallback.
354
+ * Supports searching across primary root and linked roots.
355
+ */
356
+ export async function searchInFiles(root, query, paths, glob, trackedOnly, workingDirectories, scope) {
357
+ const maxLines = MAX_SEARCH_RESULTS;
358
+ // Partition paths into primary vs linked root groups
359
+ const partitioned = paths?.length
360
+ ? await partitionSearchPaths(root, paths, scope ?? null, workingDirectories)
361
+ : { primary: [], linked: new Map() };
362
+ // Determine effective primary paths
363
+ const hasPrimaryPaths = partitioned.primary.length > 0;
364
+ const hasLinkedPaths = partitioned.linked.size > 0;
365
+ const hasExplicitPaths = paths && paths.length > 0;
366
+ // If no explicit paths provided, search primary root with working_directories restriction
367
+ const primaryEffectivePaths = hasPrimaryPaths
368
+ ? partitioned.primary
369
+ : (!hasExplicitPaths && workingDirectories?.length)
370
+ ? workingDirectories
371
+ : (!hasExplicitPaths ? undefined : undefined);
372
+ // Collect results from all roots, distributing budget fairly
373
+ const allSections = [];
374
+ let lastBackend = '';
375
+ const searchPrimary = !hasExplicitPaths || hasPrimaryPaths;
376
+ const rootCount = (searchPrimary ? 1 : 0) + partitioned.linked.size;
377
+ const perRootLimit = rootCount > 1 ? Math.max(10, Math.floor(maxLines / rootCount)) : maxLines;
378
+ // Search primary root (unless all paths are linked)
379
+ if (searchPrimary) {
380
+ const { backend, lines } = await runSearch(root, query, primaryEffectivePaths, glob, trackedOnly ?? false, perRootLimit);
381
+ lastBackend = backend;
382
+ if (lines.length > 0) {
383
+ allSections.push(...lines);
384
+ }
385
+ }
386
+ // Search each linked root
387
+ for (const [idx, { root: linkedRoot, paths: linkedPaths }] of partitioned.linked) {
388
+ const { backend, lines } = await runSearch(linkedRoot, query, linkedPaths, glob, trackedOnly ?? false, perRootLimit);
389
+ lastBackend = lastBackend || backend;
390
+ if (lines.length > 0) {
391
+ // Prefix linked root results so the reviewer knows which root they came from
392
+ const prefixed = lines.map((l) => `[linked:${idx}] ${l}`);
393
+ allSections.push(...prefixed);
394
+ }
395
+ }
396
+ if (allSections.length === 0) {
397
+ return `[search_backend: ${lastBackend || 'rg'}]\nNo matches found for: ${query}`;
398
+ }
399
+ const trimmed = allSections.slice(0, maxLines);
400
+ return `[search_backend: ${lastBackend}]\n${trimmed.join('\n')}`;
401
+ }
402
+ /**
403
+ * Read a specific line range from a file.
404
+ */
405
+ export async function readProjectFileRange(root, filePath, startLine, endLine, scope) {
406
+ const resolved = await resolveToolPath(root, filePath, scope ?? null);
407
+ const stats = await lstat(resolved);
408
+ if (!stats.isFile()) {
409
+ throw new Error(`Not a file: ${filePath}`);
410
+ }
411
+ const clampedEnd = Math.min(endLine, startLine + MAX_RANGE_LINES - 1);
412
+ const content = await readFile(resolved, 'utf-8');
413
+ const allLines = content.split('\n');
414
+ const start = Math.max(1, startLine) - 1; // 1-based to 0-based
415
+ const end = Math.min(allLines.length, clampedEnd);
416
+ const selected = allLines.slice(start, end);
417
+ const header = `Lines ${start + 1}-${end} of ${allLines.length} (${filePath})`;
418
+ const numbered = selected.map((line, i) => `${start + i + 1}\t${line}`);
419
+ return `${header}\n${numbered.join('\n')}`;
420
+ }
421
+ /**
422
+ * Get file metadata (size, type, modified time).
423
+ */
424
+ export async function statProjectFile(root, filePath, scope) {
425
+ const resolved = await resolveToolPath(root, filePath, scope ?? null);
426
+ const stats = await lstat(resolved);
427
+ const type = stats.isFile() ? 'file' : stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'other';
428
+ return JSON.stringify({
429
+ path: filePath,
430
+ type,
431
+ size: stats.size,
432
+ modified: stats.mtime.toISOString(),
433
+ });
434
+ }
435
+ /**
436
+ * Read a JSON file, optionally extracting a value at a JSON pointer path.
437
+ * Pointer format: "/key/subkey/0" (RFC 6901 simplified).
438
+ */
439
+ export async function readJsonValue(root, filePath, pointer, scope) {
440
+ const content = await readProjectFile(root, filePath, scope);
441
+ const parsed = JSON.parse(content);
442
+ if (!pointer || pointer === '' || pointer === '/') {
443
+ return JSON.stringify(parsed, null, 2);
444
+ }
445
+ // Simple JSON pointer resolution
446
+ const segments = pointer.split('/').filter(Boolean);
447
+ let current = parsed;
448
+ for (const seg of segments) {
449
+ if (current === null || current === undefined) {
450
+ throw new Error(`JSON pointer "${pointer}" not found: null at "${seg}"`);
451
+ }
452
+ if (typeof current === 'object' && !Array.isArray(current)) {
453
+ current = current[seg];
454
+ }
455
+ else if (Array.isArray(current)) {
456
+ const idx = parseInt(seg, 10);
457
+ if (isNaN(idx))
458
+ throw new Error(`JSON pointer "${pointer}": expected array index, got "${seg}"`);
459
+ current = current[idx];
460
+ }
461
+ else {
462
+ throw new Error(`JSON pointer "${pointer}": cannot traverse into ${typeof current}`);
463
+ }
464
+ }
465
+ return typeof current === 'string' ? current : JSON.stringify(current, null, 2);
466
+ }
467
+ // --- Git diff tool ---
468
+ const MAX_GIT_DIFF_BYTES = 200_000;
469
+ /**
470
+ * Run git diff within the workspace scope.
471
+ * Returns the diff output, capped at MAX_GIT_DIFF_BYTES.
472
+ *
473
+ * Defaults to `HEAD` (staged + unstaged vs last commit) rather than `HEAD~1`,
474
+ * so the reviewer sees only the current workspace changes.
475
+ *
476
+ * For untracked (new) files listed in `paths`, appends a synthetic diff
477
+ * generated via `git diff --no-index /dev/null <file>`, so newly added files
478
+ * are visible to the reviewer.
479
+ */
480
+ export async function getGitDiff(root, base, paths, scope) {
481
+ const effectiveBase = base ?? 'HEAD';
482
+ // Validate paths if provided
483
+ const validatedPaths = [];
484
+ if (paths?.length) {
485
+ const wdirs = scope?.workingDirectories ?? null;
486
+ for (const p of paths) {
487
+ if (isAbsolute(p)) {
488
+ return `Error: Path must be relative to project root: ${p}`;
489
+ }
490
+ try {
491
+ await safePath(root, p, wdirs);
492
+ validatedPaths.push(p);
493
+ }
494
+ catch (error) {
495
+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
496
+ }
497
+ }
498
+ }
499
+ else if (scope?.workingDirectories?.length) {
500
+ // Scope to working directories if no explicit paths
501
+ validatedPaths.push(...scope.workingDirectories);
502
+ }
503
+ const sections = [];
504
+ // 1. Standard git diff for tracked changes
505
+ const diffArgs = ['diff', effectiveBase];
506
+ if (validatedPaths.length > 0) {
507
+ diffArgs.push('--', ...validatedPaths);
508
+ }
509
+ try {
510
+ const tracked = await runGitDiff(root, diffArgs);
511
+ if (tracked)
512
+ sections.push(tracked);
513
+ }
514
+ catch (error) {
515
+ return `Error running git diff: ${error instanceof Error ? error.message : String(error)}`;
516
+ }
517
+ // 2. Detect untracked (new) files and generate synthetic diffs
518
+ const untrackedPaths = validatedPaths.length > 0
519
+ ? validatedPaths
520
+ : await listUntrackedFiles(root, scope?.workingDirectories);
521
+ if (untrackedPaths.length > 0) {
522
+ const untrackedDiffs = await getUntrackedDiffs(root, untrackedPaths);
523
+ if (untrackedDiffs)
524
+ sections.push(untrackedDiffs);
525
+ }
526
+ if (sections.length === 0) {
527
+ return `No differences found (base: ${effectiveBase}).`;
528
+ }
529
+ let output = sections.join('\n');
530
+ if (output.length > MAX_GIT_DIFF_BYTES) {
531
+ output = output.slice(0, MAX_GIT_DIFF_BYTES) + `\n\n[truncated — diff exceeded ${MAX_GIT_DIFF_BYTES} bytes]`;
532
+ }
533
+ return output;
534
+ }
535
+ /**
536
+ * Run a git diff command and return stdout, handling exit code 1 (differences found).
537
+ */
538
+ async function runGitDiff(root, args) {
539
+ try {
540
+ const { stdout } = await execFileAsync('git', args, {
541
+ cwd: root,
542
+ maxBuffer: MAX_GIT_DIFF_BYTES + 1024,
543
+ });
544
+ return stdout.trim();
545
+ }
546
+ catch (error) {
547
+ const err = error;
548
+ if (err.code === 1 && err.stdout) {
549
+ return err.stdout.trim();
550
+ }
551
+ throw error;
552
+ }
553
+ }
554
+ /**
555
+ * List untracked files in the workspace, respecting working_directories scope.
556
+ * Uses `git ls-files --others --exclude-standard` to find files not yet tracked by git.
557
+ */
558
+ async function listUntrackedFiles(root, workingDirectories) {
559
+ const args = ['ls-files', '--others', '--exclude-standard'];
560
+ if (workingDirectories?.length) {
561
+ args.push('--', ...workingDirectories);
562
+ }
563
+ try {
564
+ const { stdout } = await execFileAsync('git', args, { cwd: root, maxBuffer: 512 * 1024 });
565
+ return stdout.trim().split('\n').filter(Boolean);
566
+ }
567
+ catch {
568
+ return [];
569
+ }
570
+ }
571
+ /**
572
+ * For a list of paths, find untracked files and produce synthetic diffs
573
+ * so newly added files appear in the review context.
574
+ */
575
+ async function getUntrackedDiffs(root, paths) {
576
+ const diffs = [];
577
+ for (const p of paths) {
578
+ const tracked = await isTrackedFile(root, p);
579
+ if (tracked)
580
+ continue;
581
+ // File exists but is untracked — generate synthetic diff
582
+ const resolved = resolve(root, p);
583
+ try {
584
+ const stats = await lstat(resolved);
585
+ if (!stats.isFile())
586
+ continue;
587
+ if (stats.size > MAX_FILE_SIZE) {
588
+ diffs.push(`diff --git a/${p} b/${p}\nnew file\n--- /dev/null\n+++ b/${p}\n@@ Binary or large file (${stats.size} bytes) @@`);
589
+ continue;
590
+ }
591
+ }
592
+ catch {
593
+ continue; // File doesn't exist
594
+ }
595
+ try {
596
+ const { stdout } = await execFileAsync('git', ['diff', '--no-index', '--', '/dev/null', p], { cwd: root, maxBuffer: MAX_GIT_DIFF_BYTES });
597
+ if (stdout.trim())
598
+ diffs.push(stdout.trim());
599
+ }
600
+ catch (error) {
601
+ // git diff --no-index exits with 1 when differences are found
602
+ const err = error;
603
+ if (err.code === 1 && err.stdout?.trim()) {
604
+ diffs.push(err.stdout.trim());
605
+ }
606
+ }
607
+ }
608
+ return diffs.join('\n');
609
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Cache-aware cost estimate. `inputTokens` is the provider-reported total input
3
+ * bucket. `cachedInputTokens` (cache reads, 0.1× input price) and
4
+ * `cacheCreationTokens` (Anthropic cache writes, 1.25× input price) are
5
+ * already included in that total, so we subtract them from the full-price
6
+ * bucket before pricing.
7
+ */
8
+ export declare function estimateCost(model: string, inputTokens: number, outputTokens: number, cachedInputTokens?: number, cacheCreationTokens?: number): number | null;