second-opinion-mcp 0.1.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 (51) hide show
  1. package/README.md +323 -0
  2. package/dist/config.d.ts +31 -0
  3. package/dist/config.js +84 -0
  4. package/dist/context/bundler.d.ts +51 -0
  5. package/dist/context/bundler.js +481 -0
  6. package/dist/context/bundler.test.d.ts +1 -0
  7. package/dist/context/bundler.test.js +275 -0
  8. package/dist/context/git.d.ts +21 -0
  9. package/dist/context/git.js +102 -0
  10. package/dist/context/imports.d.ts +43 -0
  11. package/dist/context/imports.js +197 -0
  12. package/dist/context/imports.test.d.ts +1 -0
  13. package/dist/context/imports.test.js +147 -0
  14. package/dist/context/index.d.ts +6 -0
  15. package/dist/context/index.js +6 -0
  16. package/dist/context/session.d.ts +35 -0
  17. package/dist/context/session.js +317 -0
  18. package/dist/context/tests.d.ts +13 -0
  19. package/dist/context/tests.js +83 -0
  20. package/dist/context/types.d.ts +16 -0
  21. package/dist/context/types.js +100 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +6 -0
  24. package/dist/output/writer.d.ts +34 -0
  25. package/dist/output/writer.js +162 -0
  26. package/dist/output/writer.test.d.ts +1 -0
  27. package/dist/output/writer.test.js +175 -0
  28. package/dist/providers/base.d.ts +24 -0
  29. package/dist/providers/base.js +77 -0
  30. package/dist/providers/base.test.d.ts +1 -0
  31. package/dist/providers/base.test.js +91 -0
  32. package/dist/providers/gemini.d.ts +8 -0
  33. package/dist/providers/gemini.js +43 -0
  34. package/dist/providers/index.d.ts +8 -0
  35. package/dist/providers/index.js +31 -0
  36. package/dist/providers/openai.d.ts +8 -0
  37. package/dist/providers/openai.js +39 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +181 -0
  40. package/dist/test-utils.d.ts +71 -0
  41. package/dist/test-utils.js +136 -0
  42. package/dist/tools/review.d.ts +76 -0
  43. package/dist/tools/review.js +199 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.js +1 -0
  46. package/dist/utils/tokens.d.ts +26 -0
  47. package/dist/utils/tokens.js +27 -0
  48. package/package.json +61 -0
  49. package/scripts/install-config.js +51 -0
  50. package/second-opinion.skill.md +34 -0
  51. package/templates/second-opinion.md +54 -0
@@ -0,0 +1,481 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import { parseSession, findLatestSession, formatConversationContext, } from "./session.js";
5
+ import { getAllModifiedFiles } from "./git.js";
6
+ import { getDependenciesForFiles, getDependentsForFiles, isWithinProject, } from "./imports.js";
7
+ import { findTestFilesForFiles } from "./tests.js";
8
+ import { findTypeFilesForFiles } from "./types.js";
9
+ import { estimateTokens, BUDGET_ALLOCATION } from "../utils/tokens.js";
10
+ /**
11
+ * Expand tilde in paths to home directory
12
+ */
13
+ function expandTilde(filePath) {
14
+ if (filePath.startsWith("~/")) {
15
+ return path.join(os.homedir(), filePath.slice(2));
16
+ }
17
+ return filePath;
18
+ }
19
+ /**
20
+ * Sensitive paths that should never be included, even when explicitly requested
21
+ */
22
+ const SENSITIVE_PATH_PATTERNS = [
23
+ // Version control
24
+ /[/\\]\.git[/\\]?/i,
25
+ // SSH and encryption keys
26
+ /[/\\]\.ssh[/\\]?/i,
27
+ /[/\\]\.gnupg[/\\]?/i,
28
+ /[/\\]\.gpg[/\\]?/i,
29
+ /[/\\]id_rsa/i,
30
+ /[/\\]id_ed25519/i,
31
+ /[/\\]id_ecdsa/i,
32
+ /[/\\]\.pem$/i,
33
+ /[/\\]\.key$/i,
34
+ // Cloud provider configs
35
+ /[/\\]\.aws[/\\]?/i,
36
+ /[/\\]\.config[/\\](?:gcloud|gh|hub)[/\\]?/i,
37
+ /[/\\]\.kube[/\\]?/i,
38
+ /[/\\]\.docker[/\\]config\.json$/i,
39
+ // Package manager auth
40
+ /[/\\]\.netrc$/i,
41
+ /[/\\]\.npmrc$/i,
42
+ /[/\\]\.pypirc$/i,
43
+ // Credential files
44
+ /[/\\]credentials\.json$/i,
45
+ /[/\\]service[-_]?account.*\.json$/i,
46
+ /[/\\]\.credentials$/i,
47
+ /secrets\.(json|ya?ml)$/i,
48
+ // Environment files (commonly contain secrets)
49
+ /[/\\]\.env($|\.[^/\\]*$)/i, // .env, .env.local, .env.production, etc.
50
+ // Terraform/IaC secrets
51
+ /\.tfvars$/i,
52
+ /terraform\.tfstate/i,
53
+ // Kubernetes secrets
54
+ /secret\.ya?ml$/i,
55
+ // Shell history (may contain commands with secrets)
56
+ /\.(bash|zsh|sh)_history$/i,
57
+ ];
58
+ /**
59
+ * Check if a path points to a sensitive location
60
+ */
61
+ function isSensitivePath(filePath) {
62
+ const normalized = path.normalize(filePath);
63
+ return SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
64
+ }
65
+ /** Maximum directory recursion depth to prevent stack overflow from deep/circular structures */
66
+ const MAX_EXPAND_DEPTH = 10;
67
+ /**
68
+ * Expand a path to a list of files (handles directories recursively)
69
+ * Returns object with files and any blocked paths
70
+ */
71
+ function expandPath(inputPath, projectPath, options, depth = 0) {
72
+ const result = { files: [], blocked: [] };
73
+ // Guard against excessively deep directory structures
74
+ if (depth >= MAX_EXPAND_DEPTH) {
75
+ return result;
76
+ }
77
+ // Expand tilde
78
+ let expandedPath = expandTilde(inputPath);
79
+ // Make relative paths absolute (relative to project)
80
+ if (!path.isAbsolute(expandedPath)) {
81
+ expandedPath = path.join(projectPath, expandedPath);
82
+ }
83
+ // Normalize the path
84
+ expandedPath = path.normalize(expandedPath);
85
+ // Block sensitive paths
86
+ if (isSensitivePath(expandedPath)) {
87
+ result.blocked.push({ path: expandedPath, reason: "sensitive_path" });
88
+ return result;
89
+ }
90
+ if (!fs.existsSync(expandedPath)) {
91
+ return result;
92
+ }
93
+ // Resolve symlinks to prevent escaping to sensitive locations
94
+ let realPath;
95
+ try {
96
+ realPath = fs.realpathSync(expandedPath);
97
+ }
98
+ catch {
99
+ // Can't resolve path, skip it
100
+ return result;
101
+ }
102
+ // Check the resolved path against sensitive patterns
103
+ if (isSensitivePath(realPath)) {
104
+ result.blocked.push({ path: expandedPath, reason: "sensitive_path" });
105
+ return result;
106
+ }
107
+ // Check if file is outside project bounds (unless allowExternalFiles is true)
108
+ if (!options?.allowExternalFiles && !isWithinProject(realPath, projectPath)) {
109
+ result.blocked.push({
110
+ path: expandedPath,
111
+ reason: "outside_project_requires_allowExternalFiles",
112
+ });
113
+ return result;
114
+ }
115
+ const stat = fs.statSync(realPath);
116
+ if (stat.isFile()) {
117
+ result.files.push(realPath);
118
+ return result;
119
+ }
120
+ if (stat.isDirectory()) {
121
+ const entries = fs.readdirSync(realPath, { withFileTypes: true });
122
+ for (const entry of entries) {
123
+ // Skip hidden files and common non-code directories
124
+ if (entry.name.startsWith(".") || entry.name === "node_modules") {
125
+ continue;
126
+ }
127
+ const fullPath = path.join(realPath, entry.name);
128
+ // Resolve symlinks for each entry
129
+ let entryRealPath;
130
+ try {
131
+ entryRealPath = fs.realpathSync(fullPath);
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ // Check resolved path for sensitivity
137
+ if (isSensitivePath(entryRealPath)) {
138
+ result.blocked.push({ path: fullPath, reason: "sensitive_path" });
139
+ continue;
140
+ }
141
+ // Check project bounds for entries too
142
+ if (!options?.allowExternalFiles &&
143
+ !isWithinProject(entryRealPath, projectPath)) {
144
+ result.blocked.push({
145
+ path: fullPath,
146
+ reason: "outside_project_requires_allowExternalFiles",
147
+ });
148
+ continue;
149
+ }
150
+ const entryStat = fs.statSync(entryRealPath);
151
+ if (entryStat.isFile()) {
152
+ result.files.push(entryRealPath);
153
+ }
154
+ else if (entryStat.isDirectory()) {
155
+ // Recursively expand subdirectories
156
+ const subResult = expandPath(entryRealPath, projectPath, options, depth + 1);
157
+ result.files.push(...subResult.files);
158
+ result.blocked.push(...subResult.blocked);
159
+ }
160
+ }
161
+ }
162
+ return result;
163
+ }
164
+ /**
165
+ * Read a file and create a FileEntry
166
+ */
167
+ function readFileEntry(filePath, category, existingContent) {
168
+ try {
169
+ const content = existingContent || fs.readFileSync(filePath, "utf-8");
170
+ return {
171
+ path: filePath,
172
+ content,
173
+ category,
174
+ tokenEstimate: estimateTokens(content),
175
+ };
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ /**
182
+ * Collect and bundle all context for review
183
+ */
184
+ export async function bundleContext(options) {
185
+ const { projectPath, sessionId, includeConversation = true, includeDependencies = true, includeDependents = true, includeTests = true, includeTypes = true, includeFiles = [], allowExternalFiles = false, maxTokens = 100000, } = options;
186
+ const bundle = {
187
+ conversationContext: "",
188
+ files: [],
189
+ omittedFiles: [],
190
+ totalTokens: 0,
191
+ categories: {
192
+ session: 0,
193
+ git: 0,
194
+ dependency: 0,
195
+ dependent: 0,
196
+ test: 0,
197
+ type: 0,
198
+ explicit: 0,
199
+ },
200
+ };
201
+ // Track files we've already added
202
+ const addedFiles = new Set();
203
+ // 1. Get session context first to know conversation size
204
+ let sessionContext = null;
205
+ const sid = sessionId || findLatestSession(projectPath);
206
+ if (sid) {
207
+ sessionContext = parseSession(projectPath, sid);
208
+ }
209
+ // Add conversation context and calculate remaining budget
210
+ let remainingBudget = maxTokens;
211
+ if (includeConversation && sessionContext) {
212
+ bundle.conversationContext = formatConversationContext(sessionContext);
213
+ const conversationTokens = estimateTokens(bundle.conversationContext);
214
+ bundle.totalTokens += conversationTokens;
215
+ remainingBudget -= conversationTokens;
216
+ }
217
+ // Calculate budget for each category from remaining tokens
218
+ const budgets = {
219
+ explicit: Math.floor(remainingBudget * BUDGET_ALLOCATION.explicit),
220
+ session: Math.floor(remainingBudget * BUDGET_ALLOCATION.session),
221
+ git: Math.floor(remainingBudget * BUDGET_ALLOCATION.git),
222
+ dependency: Math.floor(remainingBudget * BUDGET_ALLOCATION.dependency),
223
+ dependent: Math.floor(remainingBudget * BUDGET_ALLOCATION.dependent),
224
+ test: Math.floor(remainingBudget * BUDGET_ALLOCATION.test),
225
+ type: Math.floor(remainingBudget * BUDGET_ALLOCATION.type),
226
+ };
227
+ // Helper to add files within a budget
228
+ const addFilesWithBudget = (files, category, budget, options) => {
229
+ let used = 0;
230
+ for (const file of files) {
231
+ // Bounds check: skip files outside project (unless explicitly included)
232
+ if (!options?.skipBoundsCheck && !isWithinProject(file.path, projectPath)) {
233
+ bundle.omittedFiles.push({
234
+ path: file.path,
235
+ category,
236
+ tokenEstimate: file.tokenEstimate,
237
+ reason: "outside_project",
238
+ });
239
+ continue;
240
+ }
241
+ if (used + file.tokenEstimate > budget) {
242
+ // Track omitted files that exceeded budget
243
+ bundle.omittedFiles.push({
244
+ path: file.path,
245
+ category,
246
+ tokenEstimate: file.tokenEstimate,
247
+ reason: "budget_exceeded",
248
+ });
249
+ continue;
250
+ }
251
+ if (!addedFiles.has(file.path)) {
252
+ bundle.files.push(file);
253
+ addedFiles.add(file.path);
254
+ used += file.tokenEstimate;
255
+ bundle.categories[category] += file.tokenEstimate;
256
+ bundle.totalTokens += file.tokenEstimate;
257
+ }
258
+ }
259
+ };
260
+ // 1a. Process explicitly included files first (highest priority)
261
+ if (includeFiles.length > 0) {
262
+ const explicitFiles = [];
263
+ for (const inputPath of includeFiles) {
264
+ const { files: expandedPaths, blocked } = expandPath(inputPath, projectPath, {
265
+ allowExternalFiles,
266
+ });
267
+ // Track blocked paths (sensitive or outside project)
268
+ for (const blockedFile of blocked) {
269
+ bundle.omittedFiles.push({
270
+ path: blockedFile.path,
271
+ category: "explicit",
272
+ tokenEstimate: 0,
273
+ reason: blockedFile.reason,
274
+ });
275
+ }
276
+ for (const filePath of expandedPaths) {
277
+ const entry = readFileEntry(filePath, "explicit");
278
+ if (entry) {
279
+ explicitFiles.push(entry);
280
+ }
281
+ }
282
+ }
283
+ // Explicit files skip bounds check since expandPath already checked
284
+ addFilesWithBudget(explicitFiles, "explicit", budgets.explicit, { skipBoundsCheck: true });
285
+ }
286
+ // 2. Collect session files (files Claude read/edited/wrote)
287
+ const sessionFiles = [];
288
+ const modifiedFiles = [];
289
+ if (sessionContext) {
290
+ // Files from session - use content Claude saw if available
291
+ const allSessionFiles = [
292
+ ...sessionContext.filesWritten,
293
+ ...sessionContext.filesEdited,
294
+ ...sessionContext.filesRead,
295
+ ];
296
+ for (const filePath of allSessionFiles) {
297
+ const cachedContent = sessionContext.fileContents.get(filePath);
298
+ const entry = readFileEntry(filePath, "session", cachedContent);
299
+ if (entry) {
300
+ sessionFiles.push(entry);
301
+ if (sessionContext.filesWritten.includes(filePath) ||
302
+ sessionContext.filesEdited.includes(filePath)) {
303
+ modifiedFiles.push(filePath);
304
+ }
305
+ }
306
+ }
307
+ }
308
+ addFilesWithBudget(sessionFiles, "session", budgets.session);
309
+ // 3. Git changes not in session
310
+ const gitChangedFiles = getAllModifiedFiles(projectPath);
311
+ const gitFiles = [];
312
+ for (const filePath of gitChangedFiles) {
313
+ if (!addedFiles.has(filePath)) {
314
+ const entry = readFileEntry(filePath, "git");
315
+ if (entry) {
316
+ gitFiles.push(entry);
317
+ modifiedFiles.push(filePath);
318
+ }
319
+ }
320
+ }
321
+ addFilesWithBudget(gitFiles, "git", budgets.git);
322
+ // 4. Dependencies (files imported by modified files)
323
+ if (includeDependencies && modifiedFiles.length > 0) {
324
+ const deps = getDependenciesForFiles(modifiedFiles, projectPath);
325
+ const depFiles = [];
326
+ for (const dep of deps) {
327
+ if (!addedFiles.has(dep)) {
328
+ const entry = readFileEntry(dep, "dependency");
329
+ if (entry) {
330
+ depFiles.push(entry);
331
+ }
332
+ }
333
+ }
334
+ // Sort by token count (smaller files first to fit more)
335
+ depFiles.sort((a, b) => a.tokenEstimate - b.tokenEstimate);
336
+ addFilesWithBudget(depFiles, "dependency", budgets.dependency);
337
+ }
338
+ // 5. Dependents (files that import modified files)
339
+ if (includeDependents && modifiedFiles.length > 0) {
340
+ const dependents = await getDependentsForFiles(modifiedFiles, projectPath);
341
+ const depFiles = [];
342
+ for (const dep of dependents) {
343
+ if (!addedFiles.has(dep)) {
344
+ const entry = readFileEntry(dep, "dependent");
345
+ if (entry) {
346
+ depFiles.push(entry);
347
+ }
348
+ }
349
+ }
350
+ depFiles.sort((a, b) => a.tokenEstimate - b.tokenEstimate);
351
+ addFilesWithBudget(depFiles, "dependent", budgets.dependent);
352
+ }
353
+ // 6. Test files
354
+ if (includeTests && modifiedFiles.length > 0) {
355
+ const tests = findTestFilesForFiles(modifiedFiles, projectPath);
356
+ const testFiles = [];
357
+ for (const test of tests) {
358
+ if (!addedFiles.has(test)) {
359
+ const entry = readFileEntry(test, "test");
360
+ if (entry) {
361
+ testFiles.push(entry);
362
+ }
363
+ }
364
+ }
365
+ addFilesWithBudget(testFiles, "test", budgets.test);
366
+ }
367
+ // 7. Type files
368
+ if (includeTypes && modifiedFiles.length > 0) {
369
+ const types = await findTypeFilesForFiles(modifiedFiles, projectPath);
370
+ const typeFiles = [];
371
+ for (const typeFile of types) {
372
+ if (!addedFiles.has(typeFile)) {
373
+ const entry = readFileEntry(typeFile, "type");
374
+ if (entry) {
375
+ typeFiles.push(entry);
376
+ }
377
+ }
378
+ }
379
+ addFilesWithBudget(typeFiles, "type", budgets.type);
380
+ }
381
+ return bundle;
382
+ }
383
+ /**
384
+ * Format the bundle as markdown for the reviewer
385
+ */
386
+ export function formatBundleAsMarkdown(bundle, projectPath) {
387
+ const lines = [];
388
+ // Conversation context
389
+ if (bundle.conversationContext) {
390
+ lines.push(bundle.conversationContext);
391
+ lines.push("---\n");
392
+ }
393
+ // Group files by category
394
+ const categories = {
395
+ explicit: [],
396
+ session: [],
397
+ git: [],
398
+ dependency: [],
399
+ dependent: [],
400
+ test: [],
401
+ type: [],
402
+ };
403
+ for (const file of bundle.files) {
404
+ categories[file.category].push(file);
405
+ }
406
+ const categoryLabels = {
407
+ explicit: "Explicitly Included Files",
408
+ session: "Modified Files (from Claude session)",
409
+ git: "Additional Git Changes",
410
+ dependency: "Dependencies (files imported by modified code)",
411
+ dependent: "Dependents (files that import modified code)",
412
+ test: "Related Tests",
413
+ type: "Type Definitions",
414
+ };
415
+ for (const [category, files] of Object.entries(categories)) {
416
+ if (files.length === 0)
417
+ continue;
418
+ lines.push(`## ${categoryLabels[category]}\n`);
419
+ for (const file of files) {
420
+ const relativePath = path.relative(projectPath, file.path);
421
+ const ext = path.extname(file.path).slice(1) || "txt";
422
+ lines.push(`### ${relativePath}\n`);
423
+ lines.push("```" + ext);
424
+ lines.push(file.content);
425
+ lines.push("```\n");
426
+ }
427
+ }
428
+ // Summary
429
+ lines.push("---\n");
430
+ lines.push("## Context Summary\n");
431
+ lines.push(`- **Total files:** ${bundle.files.length}`);
432
+ lines.push(`- **Estimated tokens:** ${bundle.totalTokens.toLocaleString()}`);
433
+ lines.push(`- **Breakdown:**`);
434
+ for (const [cat, tokens] of Object.entries(bundle.categories)) {
435
+ if (tokens > 0) {
436
+ lines.push(` - ${cat}: ${tokens.toLocaleString()} tokens`);
437
+ }
438
+ }
439
+ // Report omitted files
440
+ if (bundle.omittedFiles.length > 0) {
441
+ lines.push("");
442
+ lines.push("### Omitted Files\n");
443
+ lines.push("The following files were not included due to token budget constraints or security restrictions:\n");
444
+ const byReason = {
445
+ budget_exceeded: bundle.omittedFiles.filter((f) => f.reason === "budget_exceeded"),
446
+ outside_project: bundle.omittedFiles.filter((f) => f.reason === "outside_project"),
447
+ sensitive_path: bundle.omittedFiles.filter((f) => f.reason === "sensitive_path"),
448
+ outside_project_requires_allowExternalFiles: bundle.omittedFiles.filter((f) => f.reason === "outside_project_requires_allowExternalFiles"),
449
+ };
450
+ if (byReason.sensitive_path.length > 0) {
451
+ lines.push("**Blocked (sensitive path):**");
452
+ for (const file of byReason.sensitive_path) {
453
+ lines.push(`- ${file.path}`);
454
+ }
455
+ lines.push("");
456
+ }
457
+ if (byReason.outside_project_requires_allowExternalFiles.length > 0) {
458
+ lines.push("**Blocked (outside project - set allowExternalFiles: true to include):**");
459
+ for (const file of byReason.outside_project_requires_allowExternalFiles) {
460
+ lines.push(`- ${file.path}`);
461
+ }
462
+ lines.push("");
463
+ }
464
+ if (byReason.budget_exceeded.length > 0) {
465
+ lines.push("**Budget exceeded:**");
466
+ for (const file of byReason.budget_exceeded) {
467
+ const relativePath = path.relative(projectPath, file.path);
468
+ lines.push(`- ${relativePath} (${file.category}, ~${file.tokenEstimate.toLocaleString()} tokens)`);
469
+ }
470
+ lines.push("");
471
+ }
472
+ if (byReason.outside_project.length > 0) {
473
+ lines.push("**Outside project bounds:**");
474
+ for (const file of byReason.outside_project) {
475
+ lines.push(`- ${file.path} (${file.category})`);
476
+ }
477
+ lines.push("");
478
+ }
479
+ }
480
+ return lines.join("\n");
481
+ }
@@ -0,0 +1 @@
1
+ export {};