@kernel.chat/kbot 2.23.2 → 2.25.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.
package/dist/pair.js ADDED
@@ -0,0 +1,993 @@
1
+ // K:BOT Pair Programming Mode — Proactive file watcher + AI copilot
2
+ //
3
+ // Watches the current directory for file changes and provides real-time
4
+ // suggestions: type errors, lint issues, missing tests, security flags,
5
+ // style warnings, and AI-powered refactoring offers.
6
+ //
7
+ // Usage:
8
+ // kbot pair # Watch cwd
9
+ // kbot pair ./src # Watch specific path
10
+ // kbot pair --quiet # Errors only
11
+ // kbot pair --auto-fix # Apply safe fixes automatically
12
+ //
13
+ // Config: ~/.kbot/pair.json
14
+ import { watch, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
15
+ import { join, extname, basename, dirname, resolve } from 'node:path';
16
+ import { execSync, execFileSync } from 'node:child_process';
17
+ import { homedir } from 'node:os';
18
+ import chalk from 'chalk';
19
+ import ora from 'ora';
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+ const ACCENT = chalk.hex('#A78BFA');
24
+ const ACCENT_DIM = chalk.hex('#7C6CB0');
25
+ const GREEN = chalk.hex('#4ADE80');
26
+ const RED = chalk.hex('#F87171');
27
+ const YELLOW = chalk.hex('#FBBF24');
28
+ const CYAN = chalk.hex('#67E8F9');
29
+ const DIM = chalk.dim;
30
+ const PAIR_CONFIG_PATH = join(homedir(), '.kbot', 'pair.json');
31
+ const DEBOUNCE_MS = 500;
32
+ const IGNORED_DIRS = new Set([
33
+ 'node_modules',
34
+ '.git',
35
+ 'dist',
36
+ 'build',
37
+ 'coverage',
38
+ '.next',
39
+ '.nuxt',
40
+ '__pycache__',
41
+ '.turbo',
42
+ '.cache',
43
+ '.vite',
44
+ '.parcel-cache',
45
+ ]);
46
+ const IGNORED_FILES = new Set([
47
+ '.DS_Store',
48
+ 'Thumbs.db',
49
+ ]);
50
+ const IGNORED_EXTENSIONS = new Set([
51
+ '.lock',
52
+ '.map',
53
+ '.log',
54
+ '.ico',
55
+ '.png',
56
+ '.jpg',
57
+ '.jpeg',
58
+ '.gif',
59
+ '.svg',
60
+ '.woff',
61
+ '.woff2',
62
+ '.ttf',
63
+ '.eot',
64
+ ]);
65
+ const SOURCE_EXTENSIONS = new Set([
66
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
67
+ '.py', '.rs', '.go', '.rb', '.java', '.kt',
68
+ '.c', '.cpp', '.h', '.hpp', '.cs', '.swift',
69
+ '.vue', '.svelte', '.astro',
70
+ ]);
71
+ const TS_EXTENSIONS = new Set(['.ts', '.tsx']);
72
+ const JS_TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
73
+ const DEFAULT_CHECKS = {
74
+ typeErrors: true,
75
+ lint: true,
76
+ missingTests: true,
77
+ imports: true,
78
+ security: true,
79
+ style: true,
80
+ };
81
+ const DEFAULT_CONFIG = {
82
+ checks: DEFAULT_CHECKS,
83
+ ignorePatterns: [],
84
+ autoFix: false,
85
+ bell: false,
86
+ quiet: false,
87
+ };
88
+ // ---------------------------------------------------------------------------
89
+ // Security patterns — things we always flag
90
+ // ---------------------------------------------------------------------------
91
+ const SECURITY_PATTERNS = [
92
+ { pattern: /\beval\s*\(/, message: 'eval() usage detected — potential code injection', severity: 'error' },
93
+ { pattern: /\bnew\s+Function\s*\(/, message: 'Function() constructor — equivalent to eval()', severity: 'error' },
94
+ { pattern: /dangerouslySetInnerHTML/, message: 'dangerouslySetInnerHTML — XSS risk if input is unsanitized', severity: 'warning' },
95
+ { pattern: /['"](?:sk-|AKIA|ghp_|gho_|github_pat_|xox[bps]-|Bearer\s+ey)[A-Za-z0-9_-]{10,}['"]/, message: 'Possible hardcoded secret/API key', severity: 'error' },
96
+ { pattern: /password\s*[:=]\s*['"][^'"]{4,}['"]/, message: 'Possible hardcoded password', severity: 'error' },
97
+ { pattern: /\bexec\s*\(\s*['"`]/, message: 'Shell exec with string literal — injection risk', severity: 'warning' },
98
+ { pattern: /\b(SUPABASE_SERVICE_KEY|DATABASE_URL|PRIVATE_KEY)\s*[:=]\s*['"]/, message: 'Hardcoded sensitive environment variable', severity: 'error' },
99
+ ];
100
+ // ---------------------------------------------------------------------------
101
+ // Module state
102
+ // ---------------------------------------------------------------------------
103
+ let activeWatcher = null;
104
+ let debounceTimer = null;
105
+ const pendingChanges = new Set();
106
+ let sessionStats = { filesAnalyzed: 0, suggestionsShown: 0, fixesApplied: 0, errorsFound: 0 };
107
+ // ---------------------------------------------------------------------------
108
+ // Config management
109
+ // ---------------------------------------------------------------------------
110
+ function loadPairConfig() {
111
+ try {
112
+ if (existsSync(PAIR_CONFIG_PATH)) {
113
+ const raw = readFileSync(PAIR_CONFIG_PATH, 'utf-8');
114
+ const parsed = JSON.parse(raw);
115
+ return {
116
+ checks: { ...DEFAULT_CHECKS, ...parsed.checks },
117
+ ignorePatterns: parsed.ignorePatterns || [],
118
+ autoFix: parsed.autoFix ?? false,
119
+ bell: parsed.bell ?? false,
120
+ quiet: parsed.quiet ?? false,
121
+ };
122
+ }
123
+ }
124
+ catch {
125
+ // Corrupted config — use defaults
126
+ }
127
+ return { ...DEFAULT_CONFIG };
128
+ }
129
+ function savePairConfig(config) {
130
+ try {
131
+ const dir = dirname(PAIR_CONFIG_PATH);
132
+ if (!existsSync(dir)) {
133
+ mkdirSync(dir, { recursive: true });
134
+ }
135
+ writeFileSync(PAIR_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
136
+ }
137
+ catch {
138
+ // Non-critical — config save failure is okay
139
+ }
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // File filtering
143
+ // ---------------------------------------------------------------------------
144
+ function shouldIgnore(filePath, extraPatterns) {
145
+ const parts = filePath.split(/[/\\]/);
146
+ // Check ignored directories
147
+ for (const part of parts) {
148
+ if (IGNORED_DIRS.has(part))
149
+ return true;
150
+ }
151
+ // Check ignored files
152
+ const name = basename(filePath);
153
+ if (IGNORED_FILES.has(name))
154
+ return true;
155
+ // Check ignored extensions
156
+ const ext = extname(filePath);
157
+ if (IGNORED_EXTENSIONS.has(ext))
158
+ return true;
159
+ // Check .env files
160
+ if (name.startsWith('.env'))
161
+ return true;
162
+ // Check user-defined ignore patterns (simple glob matching)
163
+ for (const pattern of extraPatterns) {
164
+ if (matchSimpleGlob(filePath, pattern))
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+ function matchSimpleGlob(filePath, pattern) {
170
+ // Support basic patterns: *.ext, dir/*, **/dir/*
171
+ const escaped = pattern
172
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
173
+ .replace(/\*\*/g, '__DOUBLESTAR__')
174
+ .replace(/\*/g, '[^/]*')
175
+ .replace(/__DOUBLESTAR__/g, '.*');
176
+ try {
177
+ return new RegExp(`^${escaped}$`).test(filePath);
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
183
+ function isSourceFile(filePath) {
184
+ return SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase());
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Change classification
188
+ // ---------------------------------------------------------------------------
189
+ function classifyChange(fullPath, relativePath) {
190
+ let type = 'edit';
191
+ try {
192
+ statSync(fullPath);
193
+ // File exists — was it newly created or edited?
194
+ try {
195
+ const diffOutput = execSync(`git diff --name-only --diff-filter=A HEAD -- "${relativePath}"`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
196
+ if (diffOutput.includes(basename(relativePath))) {
197
+ type = 'create';
198
+ }
199
+ }
200
+ catch {
201
+ // Not in git or no previous commits — treat new files as creates
202
+ try {
203
+ execSync(`git status --porcelain -- "${relativePath}"`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim().startsWith('??') && (type = 'create');
204
+ }
205
+ catch {
206
+ // Not in a git repo — just call it an edit
207
+ }
208
+ }
209
+ }
210
+ catch {
211
+ // File doesn't exist — it was deleted
212
+ type = 'delete';
213
+ }
214
+ return { file: relativePath, fullPath, type };
215
+ }
216
+ function getGitDiff(relativePath) {
217
+ try {
218
+ // Try staged + unstaged diff
219
+ const diff = execSync(`git diff HEAD -- "${relativePath}" 2>/dev/null || git diff -- "${relativePath}" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, shell: '/bin/sh', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
220
+ return diff;
221
+ }
222
+ catch {
223
+ return '';
224
+ }
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // Analysis checks
228
+ // ---------------------------------------------------------------------------
229
+ function checkTypeErrors(change) {
230
+ const ext = extname(change.file).toLowerCase();
231
+ if (!TS_EXTENSIONS.has(ext))
232
+ return [];
233
+ const suggestions = [];
234
+ try {
235
+ // Run tsc on just this file — check for type errors
236
+ execFileSync('npx', ['tsc', '--noEmit', '--pretty', 'false', change.fullPath], {
237
+ encoding: 'utf-8',
238
+ timeout: 15000,
239
+ stdio: ['pipe', 'pipe', 'pipe'],
240
+ });
241
+ }
242
+ catch (err) {
243
+ const output = err instanceof Error && 'stderr' in err
244
+ ? String(err.stderr)
245
+ : err instanceof Error && 'stdout' in err
246
+ ? String(err.stdout)
247
+ : '';
248
+ if (output) {
249
+ // Parse tsc error output: file(line,col): error TS1234: message
250
+ const errorPattern = /\((\d+),\d+\):\s*error\s+(TS\d+):\s*(.+)/g;
251
+ let match;
252
+ while ((match = errorPattern.exec(output)) !== null) {
253
+ suggestions.push({
254
+ type: 'error',
255
+ category: 'type',
256
+ file: change.file,
257
+ line: parseInt(match[1], 10),
258
+ message: `${match[2]}: ${match[3]}`,
259
+ });
260
+ }
261
+ // If no structured errors parsed but there was output, show raw
262
+ if (suggestions.length === 0 && output.includes('error TS')) {
263
+ const lines = output.split('\n').filter(l => l.includes('error TS')).slice(0, 5);
264
+ for (const line of lines) {
265
+ suggestions.push({
266
+ type: 'error',
267
+ category: 'type',
268
+ file: change.file,
269
+ message: line.trim(),
270
+ });
271
+ }
272
+ }
273
+ }
274
+ }
275
+ return suggestions;
276
+ }
277
+ function checkLint(change) {
278
+ const ext = extname(change.file).toLowerCase();
279
+ if (!JS_TS_EXTENSIONS.has(ext))
280
+ return [];
281
+ const suggestions = [];
282
+ // Check if eslint config exists in the project
283
+ const projectRoot = findProjectRoot(change.fullPath);
284
+ if (!projectRoot)
285
+ return [];
286
+ const eslintConfigs = [
287
+ '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
288
+ 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
289
+ ];
290
+ const hasEslint = eslintConfigs.some(c => existsSync(join(projectRoot, c)));
291
+ if (!hasEslint)
292
+ return [];
293
+ try {
294
+ execFileSync('npx', ['eslint', '--format', 'compact', change.fullPath], {
295
+ encoding: 'utf-8',
296
+ timeout: 15000,
297
+ cwd: projectRoot,
298
+ stdio: ['pipe', 'pipe', 'pipe'],
299
+ });
300
+ }
301
+ catch (err) {
302
+ const output = err instanceof Error && 'stdout' in err
303
+ ? String(err.stdout)
304
+ : '';
305
+ if (output) {
306
+ // Parse eslint compact format: file: line:col - message (rule)
307
+ const errorPattern = /: line (\d+), col \d+, (\w+) - (.+)/g;
308
+ let match;
309
+ while ((match = errorPattern.exec(output)) !== null) {
310
+ const severity = match[2] === 'Error' ? 'error' : 'warning';
311
+ suggestions.push({
312
+ type: severity,
313
+ category: 'lint',
314
+ file: change.file,
315
+ line: parseInt(match[1], 10),
316
+ message: match[3],
317
+ });
318
+ }
319
+ }
320
+ }
321
+ return suggestions;
322
+ }
323
+ function checkMissingTests(change) {
324
+ const ext = extname(change.file).toLowerCase();
325
+ if (!SOURCE_EXTENSIONS.has(ext))
326
+ return [];
327
+ // Skip test files themselves
328
+ const name = basename(change.file);
329
+ if (name.includes('.test.') || name.includes('.spec.') || name.includes('__test__'))
330
+ return [];
331
+ // Skip non-logic files
332
+ if (name.startsWith('index.') || name.endsWith('.d.ts') || name.includes('.config.'))
333
+ return [];
334
+ const suggestions = [];
335
+ // Check for corresponding test file
336
+ const dir = dirname(change.fullPath);
337
+ const nameWithoutExt = basename(change.file, ext);
338
+ const testPatterns = [
339
+ join(dir, `${nameWithoutExt}.test${ext}`),
340
+ join(dir, `${nameWithoutExt}.spec${ext}`),
341
+ join(dir, '__tests__', `${nameWithoutExt}.test${ext}`),
342
+ join(dir, '__tests__', `${nameWithoutExt}.spec${ext}`),
343
+ // Also check for .test.ts when the source is .ts
344
+ join(dir, `${nameWithoutExt}.test.ts`),
345
+ join(dir, `${nameWithoutExt}.spec.ts`),
346
+ ];
347
+ const hasTest = testPatterns.some(p => existsSync(p));
348
+ if (!hasTest) {
349
+ suggestions.push({
350
+ type: 'info',
351
+ category: 'test',
352
+ file: change.file,
353
+ message: `No test file found. Consider creating ${nameWithoutExt}.test${ext}`,
354
+ });
355
+ }
356
+ return suggestions;
357
+ }
358
+ function checkImports(change) {
359
+ const ext = extname(change.file).toLowerCase();
360
+ if (!JS_TS_EXTENSIONS.has(ext))
361
+ return [];
362
+ const suggestions = [];
363
+ let content;
364
+ try {
365
+ content = readFileSync(change.fullPath, 'utf-8');
366
+ }
367
+ catch {
368
+ return [];
369
+ }
370
+ const lines = content.split('\n');
371
+ // Check for unresolved relative imports
372
+ const importPattern = /(?:import|from)\s+['"](\.[^'"]+)['"]/g;
373
+ let match;
374
+ while ((match = importPattern.exec(content)) !== null) {
375
+ const importPath = match[1];
376
+ const dir = dirname(change.fullPath);
377
+ // Resolve the import — check with and without common extensions
378
+ const resolved = resolve(dir, importPath);
379
+ const resolveExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', ''];
380
+ const exists = resolveExtensions.some(ext => existsSync(resolved + ext));
381
+ if (!exists) {
382
+ // Find line number
383
+ const lineIdx = lines.findIndex(l => l.includes(importPath));
384
+ suggestions.push({
385
+ type: 'error',
386
+ category: 'import',
387
+ file: change.file,
388
+ line: lineIdx >= 0 ? lineIdx + 1 : undefined,
389
+ message: `Unresolved import: ${importPath}`,
390
+ });
391
+ }
392
+ }
393
+ return suggestions;
394
+ }
395
+ function checkSecurity(change) {
396
+ let content;
397
+ try {
398
+ content = readFileSync(change.fullPath, 'utf-8');
399
+ }
400
+ catch {
401
+ return [];
402
+ }
403
+ const suggestions = [];
404
+ const lines = content.split('\n');
405
+ for (let i = 0; i < lines.length; i++) {
406
+ const line = lines[i];
407
+ // Skip comments
408
+ const trimmed = line.trimStart();
409
+ if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('*'))
410
+ continue;
411
+ for (const { pattern, message, severity } of SECURITY_PATTERNS) {
412
+ if (pattern.test(line)) {
413
+ suggestions.push({
414
+ type: severity,
415
+ category: 'security',
416
+ file: change.file,
417
+ line: i + 1,
418
+ message,
419
+ });
420
+ }
421
+ }
422
+ }
423
+ return suggestions;
424
+ }
425
+ function checkStyle(change) {
426
+ let content;
427
+ try {
428
+ content = readFileSync(change.fullPath, 'utf-8');
429
+ }
430
+ catch {
431
+ return [];
432
+ }
433
+ const suggestions = [];
434
+ const lines = content.split('\n');
435
+ const ext = extname(change.file).toLowerCase();
436
+ // --- console.log left in (non-test files, non-debug files) ---
437
+ if (JS_TS_EXTENSIONS.has(ext)) {
438
+ const name = basename(change.file);
439
+ const isDebugFile = name.includes('debug') || name.includes('logger') || name.includes('log');
440
+ const isTestFile = name.includes('.test.') || name.includes('.spec.');
441
+ if (!isDebugFile && !isTestFile) {
442
+ for (let i = 0; i < lines.length; i++) {
443
+ const trimmed = lines[i].trimStart();
444
+ if (trimmed.startsWith('//') || trimmed.startsWith('*'))
445
+ continue;
446
+ if (/\bconsole\.log\s*\(/.test(lines[i])) {
447
+ suggestions.push({
448
+ type: 'info',
449
+ category: 'style',
450
+ file: change.file,
451
+ line: i + 1,
452
+ message: 'console.log left in — remove before shipping?',
453
+ });
454
+ }
455
+ }
456
+ }
457
+ }
458
+ // --- TODO/FIXME/HACK comments ---
459
+ const todoPattern = /\b(TODO|FIXME|HACK|XXX)\b:?\s*(.+)/i;
460
+ for (let i = 0; i < lines.length; i++) {
461
+ const match = todoPattern.exec(lines[i]);
462
+ if (match) {
463
+ suggestions.push({
464
+ type: 'info',
465
+ category: 'style',
466
+ file: change.file,
467
+ line: i + 1,
468
+ message: `${match[1].toUpperCase()}: ${match[2].trim()}`,
469
+ });
470
+ }
471
+ }
472
+ // --- Large functions (>50 lines) ---
473
+ if (JS_TS_EXTENSIONS.has(ext)) {
474
+ detectLargeFunctions(lines, change.file, suggestions);
475
+ }
476
+ return suggestions;
477
+ }
478
+ function detectLargeFunctions(lines, file, suggestions) {
479
+ // Heuristic: find function/method declarations and track brace depth
480
+ const funcPattern = /(?:(?:export\s+)?(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>|(\w+)\s*\([^)]*\)\s*(?::\s*\w+\s*)?{)/;
481
+ let currentFunc = null;
482
+ for (let i = 0; i < lines.length; i++) {
483
+ const line = lines[i];
484
+ // Check for function start
485
+ if (!currentFunc) {
486
+ const match = funcPattern.exec(line);
487
+ if (match && line.includes('{')) {
488
+ const name = match[1] || match[2] || match[3] || 'anonymous';
489
+ currentFunc = { name, startLine: i + 1, braceDepth: 0 };
490
+ // Count braces on this line
491
+ for (const ch of line) {
492
+ if (ch === '{')
493
+ currentFunc.braceDepth++;
494
+ if (ch === '}')
495
+ currentFunc.braceDepth--;
496
+ }
497
+ continue;
498
+ }
499
+ }
500
+ // Track brace depth for current function
501
+ if (currentFunc) {
502
+ for (const ch of line) {
503
+ if (ch === '{')
504
+ currentFunc.braceDepth++;
505
+ if (ch === '}')
506
+ currentFunc.braceDepth--;
507
+ }
508
+ if (currentFunc.braceDepth <= 0) {
509
+ const length = i + 1 - currentFunc.startLine;
510
+ if (length > 50) {
511
+ suggestions.push({
512
+ type: 'info',
513
+ category: 'style',
514
+ file,
515
+ line: currentFunc.startLine,
516
+ message: `Function '${currentFunc.name}' is ${length} lines — consider breaking it up`,
517
+ });
518
+ }
519
+ currentFunc = null;
520
+ }
521
+ }
522
+ }
523
+ }
524
+ // ---------------------------------------------------------------------------
525
+ // Auto-fix (safe fixes only)
526
+ // ---------------------------------------------------------------------------
527
+ function tryAutoFix(change, suggestions) {
528
+ const applied = [];
529
+ let content;
530
+ try {
531
+ content = readFileSync(change.fullPath, 'utf-8');
532
+ }
533
+ catch {
534
+ return applied;
535
+ }
536
+ let modified = content;
537
+ let didModify = false;
538
+ // Fix 1: Remove unused imports (only if tsc flagged them)
539
+ const unusedImportSuggestions = suggestions.filter(s => s.category === 'type' && s.message.includes('is declared but'));
540
+ if (unusedImportSuggestions.length > 0) {
541
+ const lines = modified.split('\n');
542
+ for (const suggestion of unusedImportSuggestions) {
543
+ if (suggestion.line && suggestion.line <= lines.length) {
544
+ const line = lines[suggestion.line - 1];
545
+ // Only remove if it's a simple single-name import line
546
+ if (/^\s*import\s+\w+\s+from\s+['"]/.test(line) || /^\s*import\s+type\s+\w+\s+from\s+['"]/.test(line)) {
547
+ lines[suggestion.line - 1] = '';
548
+ didModify = true;
549
+ applied.push({
550
+ type: 'fix',
551
+ category: 'import',
552
+ file: change.file,
553
+ line: suggestion.line,
554
+ message: `Removed unused import`,
555
+ fix: `Deleted: ${line.trim()}`,
556
+ });
557
+ }
558
+ }
559
+ }
560
+ if (didModify) {
561
+ modified = lines.filter(l => l !== '' || !didModify).join('\n');
562
+ }
563
+ }
564
+ // Fix 2: Remove trailing whitespace
565
+ const trimmed = modified.replace(/[ \t]+$/gm, '');
566
+ if (trimmed !== modified) {
567
+ modified = trimmed;
568
+ didModify = true;
569
+ applied.push({
570
+ type: 'fix',
571
+ category: 'style',
572
+ file: change.file,
573
+ message: 'Removed trailing whitespace',
574
+ });
575
+ }
576
+ if (didModify) {
577
+ try {
578
+ writeFileSync(change.fullPath, modified, 'utf-8');
579
+ }
580
+ catch {
581
+ // Write failed — discard auto-fix
582
+ return [];
583
+ }
584
+ }
585
+ return applied;
586
+ }
587
+ // ---------------------------------------------------------------------------
588
+ // Utility
589
+ // ---------------------------------------------------------------------------
590
+ function findProjectRoot(filePath) {
591
+ let dir = dirname(filePath);
592
+ const root = resolve('/');
593
+ while (dir !== root) {
594
+ if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, 'tsconfig.json'))) {
595
+ return dir;
596
+ }
597
+ const parent = dirname(dir);
598
+ if (parent === dir)
599
+ break;
600
+ dir = parent;
601
+ }
602
+ return null;
603
+ }
604
+ // ---------------------------------------------------------------------------
605
+ // Output formatting
606
+ // ---------------------------------------------------------------------------
607
+ const CATEGORY_LABELS = {
608
+ type: 'TYPE',
609
+ lint: 'LINT',
610
+ test: 'TEST',
611
+ import: 'IMPORT',
612
+ security: 'SECURITY',
613
+ style: 'STYLE',
614
+ ai: 'AI',
615
+ };
616
+ const CATEGORY_COLORS = {
617
+ type: RED,
618
+ lint: YELLOW,
619
+ test: CYAN,
620
+ import: RED,
621
+ security: RED,
622
+ style: DIM,
623
+ ai: ACCENT,
624
+ };
625
+ function formatSuggestion(s) {
626
+ const label = CATEGORY_LABELS[s.category] || s.category.toUpperCase();
627
+ const colorFn = CATEGORY_COLORS[s.category] || DIM;
628
+ const icon = s.type === 'error' ? RED('!') :
629
+ s.type === 'warning' ? YELLOW('!') :
630
+ s.type === 'fix' ? GREEN('+') :
631
+ DIM('-');
632
+ const lineRef = s.line ? DIM(`:${s.line}`) : '';
633
+ const tag = colorFn(`[${label}]`);
634
+ return ` ${icon} ${tag} ${s.message}${lineRef}`;
635
+ }
636
+ function formatChangeHeader(change) {
637
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false });
638
+ const typeColors = {
639
+ create: GREEN,
640
+ edit: ACCENT,
641
+ delete: RED,
642
+ rename: YELLOW,
643
+ };
644
+ const colorFn = typeColors[change.type] || DIM;
645
+ return ` ${DIM(time)} ${ACCENT_DIM('pair:')} ${chalk.bold(change.file)} ${colorFn(change.type)}`;
646
+ }
647
+ function printSummaryLine(suggestions) {
648
+ const errors = suggestions.filter(s => s.type === 'error').length;
649
+ const warnings = suggestions.filter(s => s.type === 'warning').length;
650
+ const infos = suggestions.filter(s => s.type === 'info').length;
651
+ const fixes = suggestions.filter(s => s.type === 'fix').length;
652
+ const parts = [];
653
+ if (errors > 0)
654
+ parts.push(RED(`${errors} error${errors > 1 ? 's' : ''}`));
655
+ if (warnings > 0)
656
+ parts.push(YELLOW(`${warnings} warning${warnings > 1 ? 's' : ''}`));
657
+ if (infos > 0)
658
+ parts.push(DIM(`${infos} suggestion${infos > 1 ? 's' : ''}`));
659
+ if (fixes > 0)
660
+ parts.push(GREEN(`${fixes} auto-fixed`));
661
+ if (parts.length > 0) {
662
+ process.stderr.write(` ${parts.join(DIM(' | '))}\n`);
663
+ }
664
+ }
665
+ // ---------------------------------------------------------------------------
666
+ // Analysis pipeline
667
+ // ---------------------------------------------------------------------------
668
+ async function analyzeChanges(changes, config, options) {
669
+ const spinner = ora({
670
+ text: DIM(`Analyzing ${changes.length} file${changes.length > 1 ? 's' : ''}...`),
671
+ color: 'magenta',
672
+ spinner: 'dots',
673
+ stream: process.stderr,
674
+ });
675
+ spinner.start();
676
+ let totalSuggestions = [];
677
+ for (const change of changes) {
678
+ // Skip deleted files — nothing to analyze
679
+ if (change.type === 'delete') {
680
+ spinner.stop();
681
+ process.stderr.write(formatChangeHeader(change) + '\n');
682
+ continue;
683
+ }
684
+ // Skip non-source files for most checks
685
+ const isSource = isSourceFile(change.file);
686
+ const suggestions = [];
687
+ // Run enabled checks
688
+ if (isSource && config.checks.typeErrors) {
689
+ suggestions.push(...checkTypeErrors(change));
690
+ }
691
+ if (isSource && config.checks.lint) {
692
+ suggestions.push(...checkLint(change));
693
+ }
694
+ if (isSource && config.checks.missingTests) {
695
+ suggestions.push(...checkMissingTests(change));
696
+ }
697
+ if (isSource && config.checks.imports) {
698
+ suggestions.push(...checkImports(change));
699
+ }
700
+ if (config.checks.security) {
701
+ suggestions.push(...checkSecurity(change));
702
+ }
703
+ if (isSource && config.checks.style) {
704
+ suggestions.push(...checkStyle(change));
705
+ }
706
+ // Apply auto-fixes if enabled
707
+ let autoFixed = [];
708
+ if (config.autoFix || options.autoFix) {
709
+ autoFixed = tryAutoFix(change, suggestions);
710
+ suggestions.push(...autoFixed);
711
+ sessionStats.fixesApplied += autoFixed.length;
712
+ }
713
+ spinner.stop();
714
+ // In quiet mode, only show errors
715
+ const filtered = config.quiet || options.quiet
716
+ ? suggestions.filter(s => s.type === 'error' || s.type === 'fix')
717
+ : suggestions;
718
+ // Display results
719
+ if (filtered.length > 0 || !config.quiet) {
720
+ process.stderr.write(formatChangeHeader(change) + '\n');
721
+ if (filtered.length > 0) {
722
+ for (const s of filtered) {
723
+ process.stderr.write(formatSuggestion(s) + '\n');
724
+ }
725
+ printSummaryLine(filtered);
726
+ // Terminal bell on errors
727
+ if ((config.bell || options.bell) && filtered.some(s => s.type === 'error')) {
728
+ process.stderr.write('\x07');
729
+ }
730
+ }
731
+ else {
732
+ process.stderr.write(` ${GREEN('+')} ${DIM('No issues')}\n`);
733
+ }
734
+ }
735
+ // Update stats
736
+ sessionStats.filesAnalyzed++;
737
+ sessionStats.suggestionsShown += filtered.length;
738
+ sessionStats.errorsFound += suggestions.filter(s => s.type === 'error').length;
739
+ totalSuggestions.push(...suggestions);
740
+ }
741
+ // If any suggestions need AI analysis, offer it
742
+ const hasComplexIssues = totalSuggestions.some(s => s.category === 'style' && s.message.includes('lines') ||
743
+ s.type === 'error' && totalSuggestions.filter(x => x.type === 'error').length > 3);
744
+ if (hasComplexIssues && !config.quiet && !options.quiet) {
745
+ process.stderr.write(`\n ${ACCENT('?')} ${DIM('Complex issues detected. Type')} ${chalk.white('kbot pair --analyze')} ${DIM('to get AI suggestions.')}\n`);
746
+ }
747
+ }
748
+ // ---------------------------------------------------------------------------
749
+ // Main entry: startPairMode
750
+ // ---------------------------------------------------------------------------
751
+ export async function startPairMode(options = {}) {
752
+ // Stop any existing pair session
753
+ if (activeWatcher) {
754
+ stopPairMode();
755
+ }
756
+ // Load config — CLI options override config file
757
+ const config = loadPairConfig();
758
+ if (options.quiet !== undefined)
759
+ config.quiet = options.quiet;
760
+ if (options.autoFix !== undefined)
761
+ config.autoFix = options.autoFix;
762
+ if (options.bell !== undefined)
763
+ config.bell = options.bell;
764
+ if (options.checks)
765
+ config.checks = { ...config.checks, ...options.checks };
766
+ if (options.ignorePatterns)
767
+ config.ignorePatterns = [...config.ignorePatterns, ...options.ignorePatterns];
768
+ const watchPath = resolve(options.path || process.cwd());
769
+ // Verify the path exists and is a directory
770
+ try {
771
+ const stat = statSync(watchPath);
772
+ if (!stat.isDirectory()) {
773
+ process.stderr.write(` ${RED('!')} ${watchPath} is not a directory\n`);
774
+ return;
775
+ }
776
+ }
777
+ catch {
778
+ process.stderr.write(` ${RED('!')} ${watchPath} does not exist\n`);
779
+ return;
780
+ }
781
+ // Reset session stats
782
+ sessionStats = { filesAnalyzed: 0, suggestionsShown: 0, fixesApplied: 0, errorsFound: 0 };
783
+ // Print banner
784
+ process.stderr.write('\n');
785
+ process.stderr.write(` ${ACCENT('K:BOT')} ${chalk.bold('pair')} ${DIM('— watching for changes')}\n`);
786
+ process.stderr.write(` ${DIM('Path:')} ${watchPath}\n`);
787
+ const enabledChecks = Object.entries(config.checks)
788
+ .filter(([, v]) => v)
789
+ .map(([k]) => k);
790
+ process.stderr.write(` ${DIM('Checks:')} ${enabledChecks.join(', ')}\n`);
791
+ if (config.autoFix) {
792
+ process.stderr.write(` ${DIM('Auto-fix:')} ${GREEN('enabled')}\n`);
793
+ }
794
+ if (config.quiet) {
795
+ process.stderr.write(` ${DIM('Mode:')} ${YELLOW('quiet')} (errors only)\n`);
796
+ }
797
+ process.stderr.write(` ${DIM('Press Ctrl+C to stop')}\n`);
798
+ process.stderr.write('\n');
799
+ // Start watching
800
+ try {
801
+ activeWatcher = watch(watchPath, { recursive: true }, (eventType, filename) => {
802
+ if (!filename)
803
+ return;
804
+ const fullPath = join(watchPath, filename);
805
+ // Ignore filtered paths
806
+ if (shouldIgnore(filename, config.ignorePatterns))
807
+ return;
808
+ // Only watch source files (and JSON configs, YAML, etc.)
809
+ const ext = extname(filename).toLowerCase();
810
+ if (!SOURCE_EXTENSIONS.has(ext) && ext !== '.json' && ext !== '.yaml' && ext !== '.yml' && ext !== '.toml')
811
+ return;
812
+ // Add to pending changes
813
+ pendingChanges.add(filename);
814
+ // Debounce — wait for rapid saves to finish
815
+ if (debounceTimer) {
816
+ clearTimeout(debounceTimer);
817
+ }
818
+ debounceTimer = setTimeout(async () => {
819
+ // Snapshot and clear pending changes
820
+ const batch = [...pendingChanges];
821
+ pendingChanges.clear();
822
+ debounceTimer = null;
823
+ // Classify each change
824
+ const changes = batch.map(file => {
825
+ const full = join(watchPath, file);
826
+ return classifyChange(full, file);
827
+ });
828
+ // Run analysis pipeline
829
+ await analyzeChanges(changes, config, options);
830
+ }, DEBOUNCE_MS);
831
+ });
832
+ activeWatcher.on('error', (err) => {
833
+ process.stderr.write(` ${RED('!')} Watch error: ${err.message}\n`);
834
+ });
835
+ // Keep the process alive until Ctrl+C
836
+ await new Promise((resolve) => {
837
+ const cleanup = () => {
838
+ stopPairMode();
839
+ resolve();
840
+ };
841
+ process.on('SIGINT', cleanup);
842
+ process.on('SIGTERM', cleanup);
843
+ });
844
+ }
845
+ catch (err) {
846
+ const message = err instanceof Error ? err.message : String(err);
847
+ process.stderr.write(` ${RED('!')} Failed to start pair mode: ${message}\n`);
848
+ }
849
+ }
850
+ export function stopPairMode() {
851
+ if (activeWatcher) {
852
+ activeWatcher.close();
853
+ activeWatcher = null;
854
+ if (debounceTimer) {
855
+ clearTimeout(debounceTimer);
856
+ debounceTimer = null;
857
+ }
858
+ pendingChanges.clear();
859
+ // Print session summary
860
+ process.stderr.write('\n');
861
+ process.stderr.write(` ${ACCENT_DIM('pair:')} ${DIM('Session ended')}\n`);
862
+ process.stderr.write(` ${DIM('Files analyzed:')} ${sessionStats.filesAnalyzed}\n`);
863
+ process.stderr.write(` ${DIM('Suggestions:')} ${sessionStats.suggestionsShown}\n`);
864
+ process.stderr.write(` ${DIM('Errors found:')} ${sessionStats.errorsFound}\n`);
865
+ if (sessionStats.fixesApplied > 0) {
866
+ process.stderr.write(` ${DIM('Auto-fixed:')} ${GREEN(String(sessionStats.fixesApplied))}\n`);
867
+ }
868
+ process.stderr.write('\n');
869
+ }
870
+ }
871
+ /**
872
+ * Check if pair mode is currently active.
873
+ */
874
+ export function isPairActive() {
875
+ return activeWatcher !== null;
876
+ }
877
+ /**
878
+ * Get current session stats.
879
+ */
880
+ export function getPairStats() {
881
+ return { ...sessionStats };
882
+ }
883
+ // ---------------------------------------------------------------------------
884
+ // AI-powered analysis (calls agent loop)
885
+ // ---------------------------------------------------------------------------
886
+ /**
887
+ * Request AI analysis for a specific file. Uses the kbot agent loop to
888
+ * provide deeper suggestions: refactoring, architecture, patterns.
889
+ *
890
+ * This is called when the user explicitly requests it (not on every save).
891
+ */
892
+ export async function analyzeWithAgent(filePath, agentOptions) {
893
+ const { runAgent } = await import('./agent.js');
894
+ let content;
895
+ try {
896
+ content = readFileSync(filePath, 'utf-8');
897
+ }
898
+ catch {
899
+ return 'Could not read file.';
900
+ }
901
+ const diff = getGitDiff(filePath);
902
+ const ext = extname(filePath).toLowerCase();
903
+ const prompt = [
904
+ `You are a pair programming assistant reviewing a file change.`,
905
+ `File: ${filePath} (${ext})`,
906
+ diff ? `\nRecent changes (git diff):\n\`\`\`\n${diff.slice(0, 3000)}\n\`\`\`` : '',
907
+ `\nFull file:\n\`\`\`${ext.slice(1)}\n${content.slice(0, 8000)}\n\`\`\``,
908
+ `\nProvide concise, actionable suggestions:`,
909
+ `1. Any bugs or logic errors in the changed code`,
910
+ `2. Refactoring opportunities (keep it practical, not academic)`,
911
+ `3. Missing edge cases or error handling`,
912
+ `4. Performance concerns`,
913
+ `\nBe direct. No fluff. If the code is fine, say so.`,
914
+ ].join('\n');
915
+ try {
916
+ const response = await runAgent(prompt, {
917
+ agent: agentOptions?.agent || 'coder',
918
+ model: agentOptions?.model,
919
+ tier: agentOptions?.tier,
920
+ skipPlanner: true,
921
+ });
922
+ return response.content;
923
+ }
924
+ catch (err) {
925
+ const message = err instanceof Error ? err.message : String(err);
926
+ return `AI analysis failed: ${message}`;
927
+ }
928
+ }
929
+ // ---------------------------------------------------------------------------
930
+ // CLI registration
931
+ // ---------------------------------------------------------------------------
932
+ /**
933
+ * Register the `kbot pair` command with the CLI program.
934
+ *
935
+ * Usage from cli.ts:
936
+ * import { registerPairCommand } from './pair.js'
937
+ * registerPairCommand(program)
938
+ */
939
+ export function registerPairCommand(program) {
940
+ program
941
+ .command('pair [path]')
942
+ .description('Pair programming mode — watch files and get real-time suggestions')
943
+ .option('-q, --quiet', 'Only show errors, suppress suggestions')
944
+ .option('--auto-fix', 'Automatically apply safe fixes (trailing whitespace, unused imports)')
945
+ .option('--bell', 'Sound terminal bell on errors')
946
+ .option('--no-types', 'Disable TypeScript type checking')
947
+ .option('--no-lint', 'Disable ESLint checks')
948
+ .option('--no-tests', 'Disable missing test detection')
949
+ .option('--no-security', 'Disable security scanning')
950
+ .option('--no-style', 'Disable style checks')
951
+ .option('--ignore <patterns>', 'Additional ignore patterns (comma-separated)')
952
+ .option('--config', 'Show current pair config')
953
+ .option('--reset-config', 'Reset pair config to defaults')
954
+ .action(async (pairPath, opts) => {
955
+ // --config: show current config and exit
956
+ if (opts?.config) {
957
+ const config = loadPairConfig();
958
+ process.stderr.write(`\n ${ACCENT('K:BOT')} ${chalk.bold('pair')} ${DIM('config')}\n`);
959
+ process.stderr.write(` ${DIM('Path:')} ${PAIR_CONFIG_PATH}\n\n`);
960
+ process.stderr.write(` ${JSON.stringify(config, null, 2).split('\n').join('\n ')}\n\n`);
961
+ return;
962
+ }
963
+ // --reset-config: reset to defaults
964
+ if (opts?.resetConfig) {
965
+ savePairConfig(DEFAULT_CONFIG);
966
+ process.stderr.write(` ${GREEN('+')} Pair config reset to defaults\n`);
967
+ return;
968
+ }
969
+ const checks = {};
970
+ if (opts?.types === false)
971
+ checks.typeErrors = false;
972
+ if (opts?.lint === false)
973
+ checks.lint = false;
974
+ if (opts?.tests === false)
975
+ checks.missingTests = false;
976
+ if (opts?.security === false)
977
+ checks.security = false;
978
+ if (opts?.style === false)
979
+ checks.style = false;
980
+ const ignorePatterns = opts?.ignore
981
+ ? opts.ignore.split(',').map(p => p.trim())
982
+ : undefined;
983
+ await startPairMode({
984
+ path: pairPath || process.cwd(),
985
+ quiet: opts?.quiet,
986
+ autoFix: opts?.autoFix,
987
+ bell: opts?.bell,
988
+ checks: Object.keys(checks).length > 0 ? checks : undefined,
989
+ ignorePatterns,
990
+ });
991
+ });
992
+ }
993
+ //# sourceMappingURL=pair.js.map