viepilot 2.50.1 → 3.7.3

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 (49) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +17 -17
  3. package/bin/viepilot.cjs +1 -0
  4. package/bin/vp-tools.cjs +123 -1
  5. package/docs/brainstorm/session-2026-05-22.md +472 -0
  6. package/docs/dev/agents.md +51 -41
  7. package/docs/dev/architecture.md +26 -0
  8. package/docs/skills-reference.md +96 -0
  9. package/lib/adapter-context.cjs +294 -0
  10. package/lib/adapters/antigravity.cjs +8 -2
  11. package/lib/adapters/claude-code.cjs +4 -0
  12. package/lib/audit/browser-runner.cjs +102 -0
  13. package/lib/intake/adapters/browser.cjs +58 -0
  14. package/lib/intake/adapters/excel-m365.cjs +54 -6
  15. package/lib/intake/auto-intake.cjs +194 -0
  16. package/lib/intake/classifier.cjs +22 -4
  17. package/lib/intake/manifest.cjs +81 -0
  18. package/lib/intake/triage-ux.cjs +10 -2
  19. package/lib/intake/validator.cjs +97 -0
  20. package/lib/intake/writeback.cjs +169 -3
  21. package/lib/request/url-enricher.cjs +69 -0
  22. package/lib/viepilot-install.cjs +15 -0
  23. package/package.json +1 -1
  24. package/skills/vp-audit/SKILL.md +99 -3
  25. package/skills/vp-auto/SKILL.md +54 -4
  26. package/skills/vp-brainstorm/SKILL.md +69 -3
  27. package/skills/vp-crystallize/SKILL.md +52 -3
  28. package/skills/vp-debug/SKILL.md +52 -3
  29. package/skills/vp-design/SKILL.md +52 -3
  30. package/skills/vp-docs/SKILL.md +52 -3
  31. package/skills/vp-evolve/SKILL.md +52 -3
  32. package/skills/vp-info/SKILL.md +52 -3
  33. package/skills/vp-intake/SKILL.md +306 -7
  34. package/skills/vp-pause/SKILL.md +52 -3
  35. package/skills/vp-persona/SKILL.md +52 -3
  36. package/skills/vp-proposal/SKILL.md +52 -3
  37. package/skills/vp-request/SKILL.md +72 -3
  38. package/skills/vp-resume/SKILL.md +52 -3
  39. package/skills/vp-rollback/SKILL.md +52 -3
  40. package/skills/vp-skills/SKILL.md +52 -3
  41. package/skills/vp-status/SKILL.md +52 -3
  42. package/skills/vp-task/SKILL.md +52 -3
  43. package/skills/vp-ui-components/SKILL.md +52 -3
  44. package/skills/vp-update/SKILL.md +52 -3
  45. package/workflows/autonomous.md +268 -18
  46. package/workflows/brainstorm.md +222 -7
  47. package/workflows/crystallize.md +124 -6
  48. package/workflows/design.md +62 -1
  49. package/workflows/request.md +54 -8
@@ -186,7 +186,7 @@ async function readViaGraphApi(channel, projectRoot) {
186
186
  const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
187
187
 
188
188
  if (!response.values || response.values.length === 0) return [];
189
- return parseRowsWithColumnMap(response.values, channel.column_map);
189
+ return parseRowsWithColumnMap(response.values, channel.column_map, channel.id);
190
190
  }
191
191
 
192
192
  // ─── SharePoint sharing link (anonymous WOPI) ─────────────────────────────────
@@ -291,18 +291,64 @@ async function readViaSharingLink(channel) {
291
291
  const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
292
292
 
293
293
  if (!rows || rows.length === 0) return [];
294
- return parseRowsWithColumnMap(rows, channel.column_map);
294
+ return parseRowsWithColumnMap(rows, channel.column_map, channel.id);
295
+ }
296
+
297
+ // ─── Vietnamese/English header → field name auto-detection ────────────────────
298
+
299
+ const HEADER_ALIASES = {
300
+ id: ['stt', 'id', 'no', '#', 'mã', 'ma'],
301
+ title: ['title', 'tiêu đề', 'tieu de', 'tên', 'ten', 'màn hình', 'man hinh', 'tên module', 'tên testcase', 'summary'],
302
+ description: ['mô tả lỗi', 'mo ta loi', 'description', 'mô tả', 'mo ta', 'chi tiết', 'chi tiet', 'detail', 'nội dung', 'noi dung'],
303
+ status: ['status', 'trạng thái', 'trang thai', 'kết quả', 'ket qua', 'result', 'p/f/n'],
304
+ reporter: ['reporter', 'người báo', 'nguoi bao', 'tester', 'người tạo', 'nguoi tao'],
305
+ priority: ['priority', 'mức độ', 'muc do', 'độ ưu tiên', 'severity'],
306
+ };
307
+
308
+ function autoDetectColumnMap(headerRow) {
309
+ const map = {};
310
+ headerRow.forEach((cell, idx) => {
311
+ const normalized = String(cell).toLowerCase().trim();
312
+ if (!normalized) return;
313
+ for (const [field, aliases] of Object.entries(HEADER_ALIASES)) {
314
+ if (!map[field] && aliases.some(a => normalized.includes(a))) {
315
+ // Convert 0-based index to Excel letter (A=0, B=1, ...)
316
+ let col = '';
317
+ let n = idx;
318
+ do { col = String.fromCharCode(65 + (n % 26)) + col; n = Math.floor(n / 26) - 1; } while (n >= 0);
319
+ map[field] = col;
320
+ }
321
+ }
322
+ });
323
+ return map;
295
324
  }
296
325
 
297
326
  // ─── Common row → ticket mapper ───────────────────────────────────────────────
298
327
 
299
- function parseRowsWithColumnMap(values, colMap) {
328
+ function parseRowsWithColumnMap(values, colMap, channelId) {
329
+ // Find first non-empty row to use as auto-detect source if needed
330
+ const firstNonEmpty = values.find(r => r.some(c => c !== ''));
331
+
332
+ // Auto-detect or fall back to raw if still missing
333
+ const resolvedMap = (colMap && Object.keys(colMap).length > 0)
334
+ ? colMap
335
+ : (firstNonEmpty ? autoDetectColumnMap(firstNonEmpty) : {});
336
+
337
+ if (!colMap || Object.keys(colMap).length === 0) {
338
+ process.stderr.write(
339
+ `[vp-intake] No column_map configured. Auto-detected: ${JSON.stringify(resolvedMap)}. ` +
340
+ `Header row: ${JSON.stringify(firstNonEmpty || [])}\n`
341
+ );
342
+ }
343
+
300
344
  const tickets = [];
345
+ // Skip header row(s): if auto-detected, skip until first row that has a non-header value
346
+ const startRow = (colMap && Object.keys(colMap).length > 0) ? 1 : values.findIndex(r => r.some(c => c !== '')) + 1;
301
347
 
302
- for (let i = 1; i < values.length; i++) {
348
+ for (let i = startRow; i < values.length; i++) {
303
349
  const row = values[i];
304
350
  const get = (field) => {
305
- const col = colMap[field];
351
+ const col = resolvedMap[field];
306
352
  if (!col) return '';
307
353
  const idx = colLetterToIndex(col);
308
354
  return row[idx] !== undefined ? String(row[idx]) : '';
@@ -316,7 +362,7 @@ function parseRowsWithColumnMap(values, colMap) {
316
362
  date: get('date'),
317
363
  status: get('status'),
318
364
  _source_row: i,
319
- _channel_id: channel ? channel.id : undefined,
365
+ _channel_id: channelId,
320
366
  };
321
367
 
322
368
  if (!ticket.title && !ticket.description) continue;
@@ -362,4 +408,6 @@ module.exports = {
362
408
  isSharingLink,
363
409
  resolveSharePointDownloadUrl,
364
410
  parseXlsxBuffer,
411
+ autoDetectColumnMap,
412
+ HEADER_ALIASES,
365
413
  };
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { classifyTicket } = require('./classifier.cjs');
7
+ const { createRequestFile: _createRequestFile, nextRequestNumber } = require('./triage-ux.cjs');
8
+
9
+ // Internal helper — wraps triage-ux createRequestFile which isn't exported,
10
+ // so we re-implement the minimal version needed for headless auto-accept.
11
+ function createRequestFile(ticket, type, channel, projectRoot) {
12
+ const n = nextRequestNumber(type, projectRoot);
13
+ const id = `${type}-${String(n).padStart(3, '0')}`;
14
+ const now = new Date().toISOString().split('T')[0];
15
+ const typeLabel = type === 'BUG' ? 'Bug' : 'Enhancement';
16
+
17
+ const content = `# ${typeLabel}: ${ticket.title}
18
+
19
+ ## Meta
20
+ - **ID**: ${id}
21
+ - **Type**: ${typeLabel}
22
+ - **Status**: new
23
+ - **Priority**: medium
24
+ - **Created**: ${now}
25
+ - **Reporter**: ${ticket.reporter || 'Auto-intake'}
26
+ - **Source**: ${channel.name} — ticket #${ticket.id}
27
+ - **Assignee**: AI
28
+
29
+ ## Summary
30
+ ${ticket.description || ticket.title}
31
+
32
+ ## Acceptance Criteria
33
+ - [ ] (derived from description — fill in before planning)
34
+ `;
35
+
36
+ const reqDir = path.join(projectRoot, '.viepilot', 'requests');
37
+ if (!fs.existsSync(reqDir)) fs.mkdirSync(reqDir, { recursive: true });
38
+ const filePath = path.join(reqDir, `${id}.md`);
39
+ fs.writeFileSync(filePath, content, 'utf8');
40
+ return { id, filePath };
41
+ }
42
+
43
+ // ─── Constants ────────────────────────────────────────────────────────────────
44
+
45
+ const CONFIDENCE_THRESHOLD = 0.7;
46
+
47
+ // ─── Pending-review accumulation ─────────────────────────────────────────────
48
+
49
+ async function appendPendingReview(tickets, channel, pendingPath) {
50
+ const filePath = pendingPath || path.join('.viepilot', 'intake', 'pending-review.json');
51
+ const existing = fs.existsSync(filePath)
52
+ ? JSON.parse(fs.readFileSync(filePath, 'utf8'))
53
+ : { items: [] };
54
+
55
+ existing.items.push(
56
+ ...tickets.map((t) => ({
57
+ ...t,
58
+ channel: channel.name,
59
+ queued_at: new Date().toISOString(),
60
+ })),
61
+ );
62
+
63
+ const dir = path.dirname(filePath);
64
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
65
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2));
66
+ }
67
+
68
+ // ─── Triage report ────────────────────────────────────────────────────────────
69
+
70
+ async function writeTriageReport({ accepted, queued, channel, projectRoot }) {
71
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
72
+ const reportDir = path.join(projectRoot, '.viepilot', 'intake');
73
+ if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
74
+ const reportPath = path.join(reportDir, `TRIAGE-auto-${timestamp}.md`);
75
+
76
+ const lines = [
77
+ `# Auto-Intake Triage Report`,
78
+ ``,
79
+ `**Channel**: ${channel.name}`,
80
+ `**Date**: ${new Date().toISOString().split('T')[0]}`,
81
+ `**Mode**: auto (scheduled)`,
82
+ ``,
83
+ `## Auto-accepted (confidence ≥ ${CONFIDENCE_THRESHOLD})`,
84
+ ``,
85
+ ...accepted.map((t) => `- **${t.request_id}**: ${t.ticket.title} (confidence: ${(t.ticket._confidence || 0).toFixed(2)})`),
86
+ ``,
87
+ `## Queued for review (confidence < ${CONFIDENCE_THRESHOLD})`,
88
+ ``,
89
+ ...queued.map((t) => `- #${t.id || t._source_row}: ${t.title} (confidence: ${(t._confidence || 0).toFixed(2)})`),
90
+ ];
91
+
92
+ fs.writeFileSync(reportPath, lines.join('\n'), 'utf8');
93
+ return reportPath;
94
+ }
95
+
96
+ // ─── Schedule management ──────────────────────────────────────────────────────
97
+
98
+ function readSchedule(projectRoot) {
99
+ const filePath = path.join(projectRoot || '.', '.viepilot', 'intake', 'schedule.json');
100
+ if (!fs.existsSync(filePath)) return null;
101
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
102
+ }
103
+
104
+ async function createSchedule(cronExpression, channelId, projectRoot) {
105
+ const root = projectRoot || '.';
106
+ // CronCreate is a deferred tool — the invoking agent must call
107
+ // ToolSearch("select:CronCreate") before using it.
108
+ // Non-CC fallback: print guidance.
109
+ const isClaudeCode = process.env.CLAUDE_CODE === '1' || process.env.VP_ADAPTER === 'claude-code';
110
+
111
+ if (!isClaudeCode) {
112
+ console.log('Scheduling requires Claude Code. Use cron + CLI instead.');
113
+ console.log(`Suggested: cron "${cronExpression} claude /vp-intake --channel ${channelId} --auto"`);
114
+ return { success: false, error: 'Not running in Claude Code context' };
115
+ }
116
+
117
+ // In CC context, the caller (SKILL.md) must invoke CronCreate via ToolSearch.
118
+ // This function writes schedule.json after receiving the schedule_id from CronCreate.
119
+ const scheduleData = {
120
+ cron: cronExpression,
121
+ schedule_id: null, // filled by caller after CronCreate response
122
+ channel_id: channelId,
123
+ created: new Date().toISOString(),
124
+ };
125
+
126
+ const scheduleDir = path.join(root, '.viepilot', 'intake');
127
+ if (!fs.existsSync(scheduleDir)) fs.mkdirSync(scheduleDir, { recursive: true });
128
+ fs.writeFileSync(path.join(scheduleDir, 'schedule.json'), JSON.stringify(scheduleData, null, 2));
129
+ return { success: true, scheduleData };
130
+ }
131
+
132
+ async function deleteSchedule(projectRoot) {
133
+ const root = projectRoot || '.';
134
+ const filePath = path.join(root, '.viepilot', 'intake', 'schedule.json');
135
+ if (!fs.existsSync(filePath)) {
136
+ return { success: false, error: 'No schedule found — nothing to delete' };
137
+ }
138
+ const { schedule_id } = JSON.parse(fs.readFileSync(filePath, 'utf8'));
139
+ // Caller must invoke CronDelete({ schedule_id }) via ToolSearch("select:CronDelete")
140
+ fs.unlinkSync(filePath);
141
+ return { success: true, schedule_id };
142
+ }
143
+
144
+ // ─── Main auto-intake runner ──────────────────────────────────────────────────
145
+
146
+ async function runAutoIntake(tickets, channel, options = {}) {
147
+ const projectRoot = options.projectRoot || process.cwd();
148
+
149
+ // 1. Classify with confidence
150
+ const classified = tickets.map((t) => {
151
+ const { classified: cls, confidence } = classifyTicket(t);
152
+ return { ...t, _classified: cls, _confidence: confidence };
153
+ });
154
+
155
+ // 2. Partition: accepted (≥ threshold) vs. queued (< threshold)
156
+ const accepted = classified.filter((t) => t._confidence >= CONFIDENCE_THRESHOLD && t._classified !== 'UNCLEAR');
157
+ const queued = classified.filter((t) => t._confidence < CONFIDENCE_THRESHOLD || t._classified === 'UNCLEAR');
158
+
159
+ // 3. Auto-create request files for accepted tickets
160
+ const acceptedWithIds = [];
161
+ for (const t of accepted) {
162
+ const { id } = createRequestFile(t, t._classified, channel, projectRoot);
163
+ acceptedWithIds.push({ ticket: t, request_id: id });
164
+ }
165
+
166
+ // 4. Append queued tickets to pending-review.json
167
+ if (queued.length > 0) {
168
+ await appendPendingReview(queued, channel);
169
+ }
170
+
171
+ // 5. Write triage report
172
+ const reportPath = await writeTriageReport({ accepted: acceptedWithIds, queued, channel, projectRoot });
173
+
174
+ // 6. Print auto-run summary
175
+ console.log(`[AUTO INTAKE] Channel: ${channel.name} | ${tickets.length} tickets classified`);
176
+ if (acceptedWithIds.length > 0) {
177
+ console.log(`Auto-accepted (${acceptedWithIds.length}): ${acceptedWithIds.map((a) => a.request_id).join(', ')} (confidence ≥ ${CONFIDENCE_THRESHOLD})`);
178
+ }
179
+ if (queued.length > 0) {
180
+ console.log(`Queued for review (${queued.length}): ${queued.map((t) => `${t.id || t._source_row} (confidence ${(t._confidence || 0).toFixed(2)})`).join(', ')}`);
181
+ }
182
+ console.log(`Report: ${reportPath}`);
183
+
184
+ return { accepted: acceptedWithIds, queued, reportPath };
185
+ }
186
+
187
+ module.exports = {
188
+ runAutoIntake,
189
+ appendPendingReview,
190
+ createSchedule,
191
+ deleteSchedule,
192
+ readSchedule,
193
+ CONFIDENCE_THRESHOLD,
194
+ };
@@ -18,15 +18,33 @@ const ENH_KEYWORDS = [
18
18
  ];
19
19
 
20
20
  function classifyTicket(ticket) {
21
+ const titleWords = (ticket.title || '').trim().split(/\s+/).filter(Boolean);
22
+ const totalWords = titleWords.length || 1;
21
23
  const text = `${ticket.title || ''} ${ticket.description || ''}`.toLowerCase();
22
24
 
25
+ let matchedKeywords = 0;
26
+ let classified = 'UNCLEAR';
27
+
23
28
  for (const kw of BUG_KEYWORDS) {
24
- if (text.includes(kw)) return 'BUG';
29
+ if (text.includes(kw)) {
30
+ classified = 'BUG';
31
+ matchedKeywords += titleWords.filter((w) => w.toLowerCase().includes(kw)).length || 1;
32
+ break;
33
+ }
25
34
  }
26
- for (const kw of ENH_KEYWORDS) {
27
- if (text.includes(kw)) return 'ENH';
35
+
36
+ if (classified === 'UNCLEAR') {
37
+ for (const kw of ENH_KEYWORDS) {
38
+ if (text.includes(kw)) {
39
+ classified = 'ENH';
40
+ matchedKeywords += titleWords.filter((w) => w.toLowerCase().includes(kw)).length || 1;
41
+ break;
42
+ }
43
+ }
28
44
  }
29
- return 'UNCLEAR';
45
+
46
+ const confidence = classified === 'UNCLEAR' ? 0.0 : Math.min(matchedKeywords / totalWords, 1.0);
47
+ return { classified, confidence };
30
48
  }
31
49
 
32
50
  module.exports = { classifyTicket };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DEFAULT_TTL_DAYS = 7;
7
+ const MANIFEST_DIR_REL = path.join('.viepilot', 'intake');
8
+
9
+ function slugify(str) {
10
+ return String(str)
11
+ .toLowerCase()
12
+ .replace(/[^\w\s-]/g, '')
13
+ .replace(/[\s_]+/g, '-')
14
+ .replace(/^-+|-+$/g, '');
15
+ }
16
+
17
+ function manifestPath(channelId, projectRoot) {
18
+ const root = projectRoot || process.cwd();
19
+ return path.join(root, MANIFEST_DIR_REL, `${slugify(channelId)}-manifest.json`);
20
+ }
21
+
22
+ function saveManifest(channelId, manifest, projectRoot) {
23
+ const root = projectRoot || process.cwd();
24
+ const dir = path.join(root, MANIFEST_DIR_REL);
25
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
26
+
27
+ const data = {
28
+ ...manifest,
29
+ channel_id: slugify(channelId),
30
+ saved_at: new Date().toISOString(),
31
+ };
32
+ fs.writeFileSync(manifestPath(channelId, root), JSON.stringify(data, null, 2), 'utf8');
33
+ }
34
+
35
+ function loadManifest(channelId, projectRoot) {
36
+ const p = manifestPath(channelId, projectRoot);
37
+ if (!fs.existsSync(p)) return null;
38
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
39
+ }
40
+
41
+ function isManifestFresh(manifest, ttlDays) {
42
+ if (!manifest) return false;
43
+ const days = typeof ttlDays === 'number' ? ttlDays : DEFAULT_TTL_DAYS;
44
+ const ts = manifest.analyzed_at || manifest.saved_at;
45
+ if (!ts) return false;
46
+ const ageMs = Date.now() - new Date(ts).getTime();
47
+ return ageMs < days * 86400000;
48
+ }
49
+
50
+ function getColumnMap(manifest, sheetName) {
51
+ if (!manifest || !Array.isArray(manifest.sheets)) return null;
52
+ const sheet = sheetName
53
+ ? manifest.sheets.find((s) => s.name === sheetName)
54
+ : manifest.sheets[0];
55
+ if (!sheet || !sheet.columns) return null;
56
+ const map = {};
57
+ for (const [col, def] of Object.entries(sheet.columns)) {
58
+ if (def && def.field) map[def.field] = col;
59
+ }
60
+ return Object.keys(map).length ? map : {};
61
+ }
62
+
63
+ function getWriteBackConfig(manifest, sheetName) {
64
+ if (!manifest || !Array.isArray(manifest.sheets)) return null;
65
+ const sheet = sheetName
66
+ ? manifest.sheets.find((s) => s.name === sheetName)
67
+ : manifest.sheets[0];
68
+ if (!sheet || !sheet.write_back) return null;
69
+ return sheet.write_back;
70
+ }
71
+
72
+ module.exports = {
73
+ saveManifest,
74
+ loadManifest,
75
+ isManifestFresh,
76
+ getColumnMap,
77
+ getWriteBackConfig,
78
+ manifestPath,
79
+ slugify,
80
+ DEFAULT_TTL_DAYS,
81
+ };
@@ -58,6 +58,14 @@ function truncate(str, max) {
58
58
  return str.length > max ? str.slice(0, max - 1) + '…' : str;
59
59
  }
60
60
 
61
+ function getValidationBadge(ticket) {
62
+ if (!ticket._validation) return '';
63
+ const { status, file, similar_request } = ticket._validation;
64
+ if (status === 'found') return ` ✅ ${truncate(file || 'codebase match', 35)}`;
65
+ if (status === 'similar') return ` ⚠️ similar: ${similar_request || 'existing request'}`;
66
+ return ' ❓ no codebase match';
67
+ }
68
+
61
69
  async function runTriage(tickets, channel, projectRoot, askUserQuestionFn) {
62
70
  const accepted = [];
63
71
  const declined = [];
@@ -82,7 +90,7 @@ async function runTriage(tickets, channel, projectRoot, askUserQuestionFn) {
82
90
  : `${labelPrefix}: select tickets to ACCEPT`;
83
91
 
84
92
  const options = page.map((t) => ({
85
- label: `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 55)}`,
93
+ label: `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 45)}${getValidationBadge(t)}`,
86
94
  description: truncate(t.description, 100),
87
95
  }));
88
96
 
@@ -90,7 +98,7 @@ async function runTriage(tickets, channel, projectRoot, askUserQuestionFn) {
90
98
  const selectedLabels = Array.isArray(answer) ? answer : (answer ? [answer] : []);
91
99
 
92
100
  for (const t of page) {
93
- const label = `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 55)}`;
101
+ const label = `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 45)}${getValidationBadge(t)}`;
94
102
  if (selectedLabels.includes(label)) {
95
103
  accepted.push(t);
96
104
  } else {
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execSync } = require('child_process');
6
+
7
+ const BATCH_SIZE = 10;
8
+
9
+ // ─── Keyword extraction ───────────────────────────────────────────────────────
10
+
11
+ function extractKeywords(ticket) {
12
+ const title = ticket.title || '';
13
+ // Extract capitalized words (≥4 chars) — likely proper nouns / component names
14
+ const capitalized = title.match(/\b[A-Z][a-zA-Z]{3,}\b/g) || [];
15
+ // Also extract lower-case content words ≥5 chars (skip stop words)
16
+ const STOP = new Set(['with', 'from', 'that', 'this', 'have', 'will', 'when', 'then', 'into', 'about']);
17
+ const content = (title.match(/\b[a-z]{5,}\b/g) || []).filter((w) => !STOP.has(w));
18
+ const combined = [...new Set([...capitalized, ...content])];
19
+ return combined.slice(0, 3);
20
+ }
21
+
22
+ // ─── Single-ticket validation ─────────────────────────────────────────────────
23
+
24
+ async function validateTicket(ticket, projectRoot) {
25
+ const keywords = extractKeywords(ticket);
26
+ if (keywords.length === 0) return { status: 'unknown' };
27
+
28
+ let fileMatch = null;
29
+ let similarRequest = null;
30
+
31
+ // ── Codebase match: non-CC inline grep ──────────────────────────────────────
32
+ // (Claude Code path: caller may swap this out with file-scanner-agent)
33
+ const srcDirs = ['src', 'lib', 'app', 'components', 'pages'].filter(
34
+ (d) => fs.existsSync(path.join(projectRoot, d)),
35
+ );
36
+ const searchRoot = srcDirs.length > 0 ? path.join(projectRoot, srcDirs[0]) : projectRoot;
37
+
38
+ for (const kw of keywords) {
39
+ try {
40
+ const result = execSync(
41
+ `grep -r -l --include="*.ts" --include="*.tsx" --include="*.js" --include="*.cjs" -i "${kw}" "${searchRoot}" 2>/dev/null`,
42
+ { encoding: 'utf8', timeout: 5000 },
43
+ ).trim();
44
+ if (result) {
45
+ const firstFile = result.split('\n')[0];
46
+ const rel = path.relative(projectRoot, firstFile);
47
+ fileMatch = rel;
48
+ break;
49
+ }
50
+ } catch {
51
+ // grep returns exit 1 when no match — ignore
52
+ }
53
+ }
54
+
55
+ // ── Duplicate detection: scan existing requests ───────────────────────────
56
+ const reqDir = path.join(projectRoot, '.viepilot', 'requests');
57
+ if (fs.existsSync(reqDir)) {
58
+ const files = fs.readdirSync(reqDir).filter((f) => f.endsWith('.md'));
59
+ for (const f of files) {
60
+ const content = fs.readFileSync(path.join(reqDir, f), 'utf8').toLowerCase();
61
+ const matchCount = keywords.filter((kw) => content.includes(kw.toLowerCase())).length;
62
+ if (matchCount >= 2) {
63
+ similarRequest = f.replace('.md', '');
64
+ break;
65
+ }
66
+ }
67
+ }
68
+
69
+ if (similarRequest) return { status: 'similar', similar_request: similarRequest };
70
+ if (fileMatch) return { status: 'found', file: fileMatch };
71
+ return { status: 'unknown' };
72
+ }
73
+
74
+ // ─── Fan-out validation ───────────────────────────────────────────────────────
75
+
76
+ async function validateTickets(tickets, projectRoot, options = {}) {
77
+ if (options.skipValidation) return tickets;
78
+
79
+ const batches = [];
80
+ for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
81
+ batches.push(tickets.slice(i, i + BATCH_SIZE));
82
+ }
83
+
84
+ for (const batch of batches) {
85
+ const results = await Promise.all(batch.map((t) => {
86
+ // Claude Code path: if Agent tool available, caller may override validateTicket
87
+ // to use subagent_type: "file-scanner-agent" for richer results.
88
+ // Non-CC: runs inline grep + duplicate check above.
89
+ return validateTicket(t, projectRoot).catch(() => ({ status: 'unknown' }));
90
+ }));
91
+ batch.forEach((t, i) => { t._validation = results[i]; });
92
+ }
93
+
94
+ return tickets;
95
+ }
96
+
97
+ module.exports = { validateTickets, extractKeywords, validateTicket };