viepilot 2.47.0 → 2.48.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/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.48.0] - 2026-05-17
11
+
12
+ ### Added
13
+ - **ENH-082** `vp-intake` skill — import and triage tickets from external sources (Phase 123):
14
+ - **Excel/Microsoft 365 Online** adapter via Microsoft Graph API (client credentials OAuth 2.0)
15
+ - **Google Sheets** adapter via Sheets API v4 (Service Account OAuth 2.0)
16
+ - **CSV/TSV local file** adapter with configurable column mapping
17
+ - **Heuristic classifier** auto-assigns `BUG` / `ENH` / `UNCLEAR` per ticket; supports
18
+ English and Vietnamese keywords; BUG takes precedence over ENH
19
+ - **AskUserQuestion triage**: multi-select accept/decline per ticket (paginated at 4/page);
20
+ UNCLEAR tickets get a 3-choice prompt (Accept as BUG / Accept as ENH / Decline)
21
+ - **Reason collection**: declined tickets prompt user for decline reason via AUQ single-select
22
+ - **Auto-create request files**: accepted tickets generate `.viepilot/requests/BUG-N.md` or
23
+ `ENH-N.md` with source channel attribution
24
+ - **Write-back engine**: updates `VP_Status`, `VP_Comment`, `VP_RequestID` columns in source
25
+ (CSV overwrite, Google Sheets batchUpdate); write-back failure is non-fatal
26
+ - **TRIAGE session report**: `.viepilot/intake/TRIAGE-{timestamp}.md` generated after every
27
+ session with accepted/declined/unclear summary tables
28
+ - **`channels.json` config**: `.viepilot/intake/channels.json` supports multiple concurrent
29
+ channels; validated schema with required field checks
30
+ - **`vp-tools intake-init`** command: scaffolds `.viepilot/intake/channels.json` (3 example
31
+ channels), creates `.viepilot/.credentials/` (gitignored) — idempotent
32
+
10
33
  ## [2.47.0] - 2026-05-06
11
34
 
12
35
  ### Added
package/bin/vp-tools.cjs CHANGED
@@ -1288,6 +1288,21 @@ ${colors.cyan}Examples:${colors.reset}
1288
1288
  process.exit(0);
1289
1289
  },
1290
1290
 
1291
+ /**
1292
+ * Initialize .viepilot/intake/ directory with channels.json scaffold (ENH-082)
1293
+ */
1294
+ 'intake-init': (_args) => {
1295
+ const { initIntakeDir } = require('../lib/intake/channels.cjs');
1296
+ const projectCheck = validators.requireProjectRoot();
1297
+ validateArgs([projectCheck]);
1298
+ const projectRoot = projectCheck.value;
1299
+ const { channelsPath, credentialsDir } = initIntakeDir(projectRoot);
1300
+ console.log(formatSuccess('intake/ initialized'));
1301
+ console.log(` channels.json : ${channelsPath}`);
1302
+ console.log(` credentials : ${credentialsDir}`);
1303
+ console.log('\nEdit .viepilot/intake/channels.json to configure your ticket channels.');
1304
+ },
1305
+
1291
1306
  /**
1292
1307
  * Help
1293
1308
  */
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function parseCsvLine(line, delimiter) {
7
+ const result = [];
8
+ let current = '';
9
+ let inQuotes = false;
10
+
11
+ for (let i = 0; i < line.length; i++) {
12
+ const ch = line[i];
13
+ if (ch === '"') {
14
+ if (inQuotes && line[i + 1] === '"') {
15
+ current += '"';
16
+ i++;
17
+ } else {
18
+ inQuotes = !inQuotes;
19
+ }
20
+ } else if (ch === delimiter && !inQuotes) {
21
+ result.push(current.trim());
22
+ current = '';
23
+ } else {
24
+ current += ch;
25
+ }
26
+ }
27
+ result.push(current.trim());
28
+ return result;
29
+ }
30
+
31
+ function applyColumnMap(headers, row, columnMap) {
32
+ const headerIndex = {};
33
+ headers.forEach((h, i) => { headerIndex[h.trim()] = i; });
34
+
35
+ const get = (fieldName) => {
36
+ const mapped = columnMap[fieldName];
37
+ if (!mapped) return '';
38
+ const idx = headerIndex[mapped];
39
+ return idx !== undefined ? (row[idx] || '') : '';
40
+ };
41
+
42
+ return {
43
+ id: get('id'),
44
+ title: get('title'),
45
+ description: get('description'),
46
+ reporter: get('reporter'),
47
+ date: get('date'),
48
+ status: get('status'),
49
+ };
50
+ }
51
+
52
+ async function readCsv(channel, projectRoot) {
53
+ const filePath = path.resolve(projectRoot || process.cwd(), channel.path);
54
+ if (!fs.existsSync(filePath)) {
55
+ throw new Error(`CSV file not found: ${filePath}`);
56
+ }
57
+
58
+ const ext = path.extname(filePath).toLowerCase();
59
+ const delimiter = ext === '.tsv' ? '\t' : ',';
60
+
61
+ const content = fs.readFileSync(filePath, 'utf8');
62
+ const lines = content.split(/\r?\n/).filter((l) => l.trim() !== '');
63
+
64
+ if (lines.length === 0) return [];
65
+
66
+ const headers = parseCsvLine(lines[0], delimiter);
67
+ const tickets = [];
68
+
69
+ for (let i = 1; i < lines.length; i++) {
70
+ const row = parseCsvLine(lines[i], delimiter);
71
+ const ticket = applyColumnMap(headers, row, channel.column_map);
72
+ if (!ticket.title && !ticket.description) continue;
73
+ ticket._source_row = i;
74
+ ticket._channel_id = channel.id;
75
+ tickets.push(ticket);
76
+ }
77
+
78
+ return tickets;
79
+ }
80
+
81
+ module.exports = { readCsv };
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+
7
+ const CREDENTIALS_REL = path.join('.viepilot', '.credentials', 'm365-credentials.json');
8
+
9
+ const SETUP_GUIDE = `
10
+ Microsoft 365 / Excel setup:
11
+ 1. Go to portal.azure.com → Azure Active Directory → App Registrations → New
12
+ 2. Add API permission: Microsoft Graph → Files.Read.All (Application) → Grant admin consent
13
+ 3. Certificates & Secrets → New client secret → copy value immediately
14
+ 4. Save credentials to: .viepilot/.credentials/m365-credentials.json
15
+ { "tenant_id": "...", "client_id": "...", "client_secret": "..." }
16
+ 5. In channels.json, set "workbook_id" to the file's drive item ID
17
+ (visible in the file's URL on SharePoint/OneDrive)
18
+ 6. Re-run vp-intake
19
+ `;
20
+
21
+ class AuthRequiredError extends Error {
22
+ constructor(msg) {
23
+ super(msg);
24
+ this.name = 'AuthRequiredError';
25
+ }
26
+ }
27
+
28
+ let _tokenCache = null;
29
+
30
+ function colLetterToIndex(letter) {
31
+ let index = 0;
32
+ for (const ch of letter.toUpperCase()) {
33
+ index = index * 26 + ch.charCodeAt(0) - 64;
34
+ }
35
+ return index - 1;
36
+ }
37
+
38
+ function httpsPost(url, body, headers) {
39
+ return new Promise((resolve, reject) => {
40
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
41
+ const urlObj = new URL(url);
42
+ const req = https.request({
43
+ hostname: urlObj.hostname,
44
+ path: urlObj.pathname + urlObj.search,
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(payload), ...headers },
47
+ }, (res) => {
48
+ let data = '';
49
+ res.on('data', (chunk) => { data += chunk; });
50
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
51
+ });
52
+ req.on('error', reject);
53
+ req.write(payload);
54
+ req.end();
55
+ });
56
+ }
57
+
58
+ function httpsGet(url, headers) {
59
+ return new Promise((resolve, reject) => {
60
+ const urlObj = new URL(url);
61
+ const req = https.request({
62
+ hostname: urlObj.hostname,
63
+ path: urlObj.pathname + urlObj.search,
64
+ method: 'GET',
65
+ headers: { Accept: 'application/json', ...headers },
66
+ }, (res) => {
67
+ let data = '';
68
+ res.on('data', (chunk) => { data += chunk; });
69
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
70
+ });
71
+ req.on('error', reject);
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ function encodeFormData(obj) {
77
+ return Object.entries(obj)
78
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
79
+ .join('&');
80
+ }
81
+
82
+ async function getAccessToken(creds) {
83
+ const now = Math.floor(Date.now() / 1000);
84
+ if (_tokenCache && _tokenCache.expires_at > now + 60) {
85
+ return _tokenCache.access_token;
86
+ }
87
+
88
+ const url = `https://login.microsoftonline.com/${creds.tenant_id}/oauth2/v2.0/token`;
89
+ const res = await httpsPost(url, encodeFormData({
90
+ grant_type: 'client_credentials',
91
+ client_id: creds.client_id,
92
+ client_secret: creds.client_secret,
93
+ scope: 'https://graph.microsoft.com/.default',
94
+ }));
95
+
96
+ if (!res.access_token) {
97
+ throw new Error(`M365 auth failed: ${JSON.stringify(res)}`);
98
+ }
99
+
100
+ _tokenCache = { access_token: res.access_token, expires_at: now + (res.expires_in || 3600) };
101
+ return res.access_token;
102
+ }
103
+
104
+ async function readExcelM365(channel, projectRoot) {
105
+ const root = projectRoot || process.cwd();
106
+ const credPath = path.join(root, CREDENTIALS_REL);
107
+
108
+ if (!fs.existsSync(credPath)) {
109
+ throw new AuthRequiredError(SETUP_GUIDE);
110
+ }
111
+
112
+ let creds;
113
+ try {
114
+ creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
115
+ } catch (e) {
116
+ throw new Error(`Failed to parse m365-credentials.json: ${e.message}`);
117
+ }
118
+
119
+ if (!creds.tenant_id || !creds.client_id || !creds.client_secret) {
120
+ throw new AuthRequiredError(`m365-credentials.json is incomplete.${SETUP_GUIDE}`);
121
+ }
122
+
123
+ const token = await getAccessToken(creds);
124
+ const sheetName = encodeURIComponent(channel.sheet_name || 'Sheet1');
125
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${channel.workbook_id}/workbook/worksheets/${sheetName}/usedRange`;
126
+ const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
127
+
128
+ if (!response.values || response.values.length === 0) return [];
129
+
130
+ const colMap = channel.column_map;
131
+ const tickets = [];
132
+
133
+ for (let i = 1; i < response.values.length; i++) {
134
+ const row = response.values[i];
135
+ const get = (field) => {
136
+ const col = colMap[field];
137
+ if (!col) return '';
138
+ const idx = colLetterToIndex(col);
139
+ return row[idx] !== undefined ? String(row[idx]) : '';
140
+ };
141
+
142
+ const ticket = {
143
+ id: get('id'),
144
+ title: get('title'),
145
+ description: get('description'),
146
+ reporter: get('reporter'),
147
+ date: get('date'),
148
+ status: get('status'),
149
+ _source_row: i,
150
+ _channel_id: channel.id,
151
+ };
152
+
153
+ if (!ticket.title && !ticket.description) continue;
154
+ tickets.push(ticket);
155
+ }
156
+
157
+ return tickets;
158
+ }
159
+
160
+ function clearTokenCache() { _tokenCache = null; }
161
+
162
+ module.exports = { readExcelM365, AuthRequiredError, clearTokenCache };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const https = require('https');
7
+
8
+ const CREDENTIALS_REL = path.join('.viepilot', '.credentials', 'google-service-account.json');
9
+
10
+ const SETUP_GUIDE = `
11
+ Google Sheets setup:
12
+ 1. Go to console.cloud.google.com → Create project → Enable Sheets API
13
+ 2. IAM → Service Accounts → Create → Download JSON key
14
+ 3. Save key to: .viepilot/.credentials/google-service-account.json
15
+ 4. Share your Google Sheet with the service account email (viewer access)
16
+ 5. Re-run vp-intake
17
+ `;
18
+
19
+ class AuthRequiredError extends Error {
20
+ constructor(msg) {
21
+ super(msg);
22
+ this.name = 'AuthRequiredError';
23
+ }
24
+ }
25
+
26
+ function colLetterToIndex(letter) {
27
+ let index = 0;
28
+ for (const ch of letter.toUpperCase()) {
29
+ index = index * 26 + ch.charCodeAt(0) - 64;
30
+ }
31
+ return index - 1;
32
+ }
33
+
34
+ function httpsPost(url, body, headers) {
35
+ return new Promise((resolve, reject) => {
36
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
37
+ const urlObj = new URL(url);
38
+ const req = https.request({
39
+ hostname: urlObj.hostname,
40
+ path: urlObj.pathname + urlObj.search,
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(payload), ...headers },
43
+ }, (res) => {
44
+ let data = '';
45
+ res.on('data', (chunk) => { data += chunk; });
46
+ res.on('end', () => {
47
+ try { resolve(JSON.parse(data)); } catch { resolve(data); }
48
+ });
49
+ });
50
+ req.on('error', reject);
51
+ req.write(payload);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ function httpsGet(url, headers) {
57
+ return new Promise((resolve, reject) => {
58
+ const urlObj = new URL(url);
59
+ const req = https.request({
60
+ hostname: urlObj.hostname,
61
+ path: urlObj.pathname + urlObj.search,
62
+ method: 'GET',
63
+ headers: { Accept: 'application/json', ...headers },
64
+ }, (res) => {
65
+ let data = '';
66
+ res.on('data', (chunk) => { data += chunk; });
67
+ res.on('end', () => {
68
+ try { resolve(JSON.parse(data)); } catch { resolve(data); }
69
+ });
70
+ });
71
+ req.on('error', reject);
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ function encodeFormData(obj) {
77
+ return Object.entries(obj)
78
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
79
+ .join('&');
80
+ }
81
+
82
+ async function getAccessToken(keyJson, projectRoot) {
83
+ const now = Math.floor(Date.now() / 1000);
84
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
85
+ const payload = Buffer.from(JSON.stringify({
86
+ iss: keyJson.client_email,
87
+ scope: 'https://www.googleapis.com/auth/spreadsheets.readonly',
88
+ aud: 'https://oauth2.googleapis.com/token',
89
+ exp: now + 3600,
90
+ iat: now,
91
+ })).toString('base64url');
92
+
93
+ const crypto = require('crypto');
94
+ const sign = crypto.createSign('RSA-SHA256');
95
+ sign.update(`${header}.${payload}`);
96
+ const signature = sign.sign(keyJson.private_key, 'base64url');
97
+ const jwt = `${header}.${payload}.${signature}`;
98
+
99
+ const tokenRes = await httpsPost(
100
+ 'https://oauth2.googleapis.com/token',
101
+ encodeFormData({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
102
+ );
103
+ if (!tokenRes.access_token) {
104
+ throw new Error(`Google auth failed: ${JSON.stringify(tokenRes)}`);
105
+ }
106
+ return tokenRes.access_token;
107
+ }
108
+
109
+ async function readGoogleSheet(channel, projectRoot) {
110
+ const root = projectRoot || process.cwd();
111
+ const credPath = path.join(root, CREDENTIALS_REL);
112
+
113
+ if (!fs.existsSync(credPath)) {
114
+ throw new AuthRequiredError(SETUP_GUIDE);
115
+ }
116
+
117
+ let keyJson;
118
+ try {
119
+ keyJson = JSON.parse(fs.readFileSync(credPath, 'utf8'));
120
+ } catch (e) {
121
+ throw new Error(`Failed to parse google-service-account.json: ${e.message}`);
122
+ }
123
+
124
+ const accessToken = await getAccessToken(keyJson, root);
125
+ const sheetName = encodeURIComponent(channel.sheet_name || 'Sheet1');
126
+ const url = `https://sheets.googleapis.com/v4/spreadsheets/${channel.spreadsheet_id}/values/${sheetName}!A:Z`;
127
+ const response = await httpsGet(url, { Authorization: `Bearer ${accessToken}` });
128
+
129
+ if (!response.values || response.values.length === 0) return [];
130
+
131
+ const colMap = channel.column_map;
132
+ const tickets = [];
133
+
134
+ for (let i = 1; i < response.values.length; i++) {
135
+ const row = response.values[i];
136
+ const get = (field) => {
137
+ const col = colMap[field];
138
+ if (!col) return '';
139
+ const idx = colLetterToIndex(col);
140
+ return row[idx] || '';
141
+ };
142
+
143
+ const ticket = {
144
+ id: get('id'),
145
+ title: get('title'),
146
+ description: get('description'),
147
+ reporter: get('reporter'),
148
+ date: get('date'),
149
+ status: get('status'),
150
+ _source_row: i,
151
+ _channel_id: channel.id,
152
+ };
153
+
154
+ if (!ticket.title && !ticket.description) continue;
155
+ tickets.push(ticket);
156
+ }
157
+
158
+ return tickets;
159
+ }
160
+
161
+ module.exports = { readGoogleSheet, AuthRequiredError };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const CHANNELS_REL = path.join('.viepilot', 'intake', 'channels.json');
7
+
8
+ const REQUIRED_CHANNEL_FIELDS = ['id', 'type', 'name'];
9
+ const REQUIRED_COLUMN_MAP_FIELDS = ['title', 'description'];
10
+ const VALID_TYPES = ['csv', 'google_sheets', 'excel_m365'];
11
+
12
+ const CHANNELS_SCAFFOLD = {
13
+ channels: [
14
+ {
15
+ id: 'csv-example',
16
+ type: 'csv',
17
+ name: 'Local CSV Export',
18
+ path: './reports/tickets.csv',
19
+ column_map: {
20
+ id: 'ticket_id',
21
+ title: 'summary',
22
+ description: 'details',
23
+ reporter: 'author',
24
+ date: 'created_at',
25
+ status: 'status',
26
+ },
27
+ },
28
+ {
29
+ id: 'gsheet-example',
30
+ type: 'google_sheets',
31
+ name: 'Google Sheet Tickets',
32
+ spreadsheet_id: 'REPLACE_WITH_SPREADSHEET_ID',
33
+ sheet_name: 'Sheet1',
34
+ column_map: {
35
+ id: 'A',
36
+ title: 'B',
37
+ description: 'C',
38
+ reporter: 'D',
39
+ date: 'E',
40
+ status: 'F',
41
+ },
42
+ },
43
+ {
44
+ id: 'm365-example',
45
+ type: 'excel_m365',
46
+ name: 'M365 Excel Tickets',
47
+ workbook_id: 'REPLACE_WITH_WORKBOOK_ID',
48
+ sheet_name: 'Tickets',
49
+ column_map: {
50
+ id: 'A',
51
+ title: 'B',
52
+ description: 'C',
53
+ reporter: 'D',
54
+ date: 'E',
55
+ status: 'F',
56
+ },
57
+ },
58
+ ],
59
+ };
60
+
61
+ function loadChannels(projectRoot) {
62
+ const channelsPath = path.join(projectRoot, CHANNELS_REL);
63
+ if (!fs.existsSync(channelsPath)) {
64
+ return { channels: [] };
65
+ }
66
+ try {
67
+ return JSON.parse(fs.readFileSync(channelsPath, 'utf8'));
68
+ } catch (e) {
69
+ throw new Error(`Failed to parse channels.json: ${e.message}`);
70
+ }
71
+ }
72
+
73
+ function validateChannel(channel) {
74
+ for (const field of REQUIRED_CHANNEL_FIELDS) {
75
+ if (!channel[field]) {
76
+ throw new Error(`Channel missing required field: "${field}"`);
77
+ }
78
+ }
79
+ if (!VALID_TYPES.includes(channel.type)) {
80
+ throw new Error(`Invalid channel type "${channel.type}". Must be one of: ${VALID_TYPES.join(', ')}`);
81
+ }
82
+ if (!channel.column_map) {
83
+ throw new Error(`Channel "${channel.id}" missing "column_map"`);
84
+ }
85
+ for (const field of REQUIRED_COLUMN_MAP_FIELDS) {
86
+ if (!channel.column_map[field]) {
87
+ throw new Error(`Channel "${channel.id}" column_map missing required field: "${field}"`);
88
+ }
89
+ }
90
+ if (channel.type === 'csv' && !channel.path) {
91
+ throw new Error(`CSV channel "${channel.id}" must have a "path" field`);
92
+ }
93
+ if (channel.type === 'google_sheets' && !channel.spreadsheet_id) {
94
+ throw new Error(`Google Sheets channel "${channel.id}" must have a "spreadsheet_id" field`);
95
+ }
96
+ if (channel.type === 'excel_m365' && !channel.workbook_id) {
97
+ throw new Error(`Excel/M365 channel "${channel.id}" must have a "workbook_id" field`);
98
+ }
99
+ return true;
100
+ }
101
+
102
+ function getChannelById(id, channels) {
103
+ return (channels || []).find((c) => c.id === id) || null;
104
+ }
105
+
106
+ function initIntakeDir(projectRoot) {
107
+ const intakeDir = path.join(projectRoot, '.viepilot', 'intake');
108
+ const channelsPath = path.join(intakeDir, 'channels.json');
109
+ const credentialsDir = path.join(projectRoot, '.viepilot', '.credentials');
110
+ const gitkeepPath = path.join(credentialsDir, '.gitkeep');
111
+ const gitignorePath = path.join(projectRoot, '.gitignore');
112
+
113
+ if (!fs.existsSync(intakeDir)) {
114
+ fs.mkdirSync(intakeDir, { recursive: true });
115
+ }
116
+
117
+ if (!fs.existsSync(channelsPath)) {
118
+ fs.writeFileSync(channelsPath, JSON.stringify(CHANNELS_SCAFFOLD, null, 2) + '\n', 'utf8');
119
+ }
120
+
121
+ if (!fs.existsSync(credentialsDir)) {
122
+ fs.mkdirSync(credentialsDir, { recursive: true });
123
+ }
124
+ if (!fs.existsSync(gitkeepPath)) {
125
+ fs.writeFileSync(gitkeepPath, '', 'utf8');
126
+ }
127
+
128
+ if (fs.existsSync(gitignorePath)) {
129
+ const existing = fs.readFileSync(gitignorePath, 'utf8');
130
+ const entry = '.viepilot/.credentials/';
131
+ if (!existing.includes(entry)) {
132
+ fs.appendFileSync(gitignorePath, `\n# ViePilot OAuth credentials (ENH-082)\n${entry}\n`, 'utf8');
133
+ }
134
+ }
135
+
136
+ return { intakeDir, channelsPath, credentialsDir };
137
+ }
138
+
139
+ module.exports = { loadChannels, validateChannel, getChannelById, initIntakeDir, CHANNELS_SCAFFOLD };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const BUG_KEYWORDS = [
4
+ 'bug', 'error', 'fail', 'crash', 'broken', 'fix', 'exception', 'regression',
5
+ 'wrong', 'incorrect', 'unexpected', 'null', 'undefined', '500', '404',
6
+ // Vietnamese
7
+ 'lỗi', 'sự cố', 'không hoạt động', 'hỏng', 'sai', 'vá lỗi', 'bị lỗi',
8
+ ];
9
+
10
+ const ENH_KEYWORDS = [
11
+ 'add', 'new', 'feature', 'improve', 'enhance', 'update', 'upgrade',
12
+ 'support', 'request', 'implement', 'integrate', 'extend',
13
+ // Vietnamese
14
+ 'thêm', 'nâng cấp', 'tính năng', 'cải tiến', 'tích hợp', 'mở rộng',
15
+ 'yêu cầu', 'bổ sung', 'phát triển',
16
+ ];
17
+
18
+ function classifyTicket(ticket) {
19
+ const text = `${ticket.title || ''} ${ticket.description || ''}`.toLowerCase();
20
+
21
+ for (const kw of BUG_KEYWORDS) {
22
+ if (text.includes(kw)) return 'BUG';
23
+ }
24
+ for (const kw of ENH_KEYWORDS) {
25
+ if (text.includes(kw)) return 'ENH';
26
+ }
27
+ return 'UNCLEAR';
28
+ }
29
+
30
+ module.exports = { classifyTicket };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function truncate(str, max) {
7
+ if (!str) return '';
8
+ return str.length > max ? str.slice(0, max - 1) + '…' : str;
9
+ }
10
+
11
+ function generateTriageReport(channel, triageResult, projectRoot) {
12
+ const root = projectRoot || process.cwd();
13
+ const now = new Date();
14
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
15
+ const tsDisplay = now.toISOString();
16
+
17
+ const accepted = triageResult.accepted || [];
18
+ const declined = triageResult.declined || [];
19
+ const unclear = triageResult.unclear || [];
20
+ const total = accepted.length + declined.length + unclear.length;
21
+
22
+ const acceptedRows = accepted.map(({ ticket, request_id }) =>
23
+ `| ${ticket.id || ticket._source_row} | ${truncate(ticket.title, 50)} | ${ticket._classified || '?'} | ${request_id} |`
24
+ ).join('\n') || '| — | — | — | — |';
25
+
26
+ const declinedRows = declined.map(({ ticket, reason }) =>
27
+ `| ${ticket.id || ticket._source_row} | ${truncate(ticket.title, 50)} | ${truncate(reason, 60)} |`
28
+ ).join('\n') || '| — | — | — |';
29
+
30
+ const unclearRows = unclear.map(({ ticket }) =>
31
+ `| ${ticket.id || ticket._source_row} | ${truncate(ticket.title, 50)} |`
32
+ ).join('\n') || '| — | — |';
33
+
34
+ const pkgVersion = (() => {
35
+ try {
36
+ return require(path.join(root, 'package.json')).version;
37
+ } catch {
38
+ return 'unknown';
39
+ }
40
+ })();
41
+
42
+ const report = `# Triage Report — ${channel.name}
43
+
44
+ **Session**: ${tsDisplay}
45
+ **Channel**: ${channel.name} (${channel.type})
46
+ **Total Tickets**: ${total}
47
+
48
+ ## Summary
49
+
50
+ | Decision | Count |
51
+ |----------|-------|
52
+ | Accepted | ${accepted.length} |
53
+ | Declined | ${declined.length} |
54
+ | Unclear | ${unclear.length} |
55
+
56
+ ## Accepted Tickets
57
+
58
+ | Source ID | Title | Type | Request Created |
59
+ |-----------|-------|------|-----------------|
60
+ ${acceptedRows}
61
+
62
+ ## Declined Tickets
63
+
64
+ | Source ID | Title | Reason |
65
+ |-----------|-------|--------|
66
+ ${declinedRows}
67
+
68
+ ## Unclear Tickets (not actioned)
69
+
70
+ | Source ID | Title |
71
+ |-----------|-------|
72
+ ${unclearRows}
73
+
74
+ ---
75
+ *Generated by ViePilot vp-intake v${pkgVersion}*
76
+ `;
77
+
78
+ const intakeDir = path.join(root, '.viepilot', 'intake');
79
+ if (!fs.existsSync(intakeDir)) {
80
+ fs.mkdirSync(intakeDir, { recursive: true });
81
+ }
82
+
83
+ const reportPath = path.join(intakeDir, `TRIAGE-${ts}.md`);
84
+ fs.writeFileSync(reportPath, report, 'utf8');
85
+ return reportPath;
86
+ }
87
+
88
+ module.exports = { generateTriageReport };
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DECLINE_REASONS = [
7
+ 'Duplicate — already tracked',
8
+ 'Out of scope for this project',
9
+ "Won't fix — low value",
10
+ 'Deferred to future milestone',
11
+ ];
12
+
13
+ function nextRequestNumber(type, projectRoot) {
14
+ const prefix = type === 'BUG' ? 'BUG-' : 'ENH-';
15
+ const reqDir = path.join(projectRoot, '.viepilot', 'requests');
16
+ if (!fs.existsSync(reqDir)) return 1;
17
+
18
+ const existing = fs.readdirSync(reqDir)
19
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.md'))
20
+ .map((f) => parseInt(f.replace(prefix, '').replace('.md', ''), 10))
21
+ .filter((n) => !isNaN(n));
22
+
23
+ return existing.length ? Math.max(...existing) + 1 : 1;
24
+ }
25
+
26
+ function createRequestFile(ticket, type, channel, projectRoot) {
27
+ const n = nextRequestNumber(type, projectRoot);
28
+ const id = `${type}-${String(n).padStart(3, '0')}`;
29
+ const now = new Date().toISOString().split('T')[0];
30
+ const typeLabel = type === 'BUG' ? 'Bug' : 'Enhancement';
31
+
32
+ const content = `# ${typeLabel}: ${ticket.title}
33
+
34
+ ## Meta
35
+ - **ID**: ${id}
36
+ - **Type**: ${typeLabel}
37
+ - **Status**: new
38
+ - **Priority**: medium
39
+ - **Created**: ${now}
40
+ - **Reporter**: ${ticket.reporter || 'External source'}
41
+ - **Source**: ${channel.name} — ticket #${ticket.id}
42
+ - **Assignee**: AI
43
+
44
+ ## Summary
45
+ ${ticket.description || ticket.title}
46
+
47
+ ## Acceptance Criteria
48
+ - [ ] (derived from description — fill in before planning)
49
+ `;
50
+
51
+ const filePath = path.join(projectRoot, '.viepilot', 'requests', `${id}.md`);
52
+ fs.writeFileSync(filePath, content, 'utf8');
53
+ return { id, filePath };
54
+ }
55
+
56
+ function truncate(str, max) {
57
+ if (!str) return '';
58
+ return str.length > max ? str.slice(0, max - 1) + '…' : str;
59
+ }
60
+
61
+ async function runTriage(tickets, channel, projectRoot, askUserQuestionFn) {
62
+ const accepted = [];
63
+ const declined = [];
64
+ const unclear = [];
65
+
66
+ const bugTickets = tickets.filter((t) => t._classified === 'BUG');
67
+ const enhTickets = tickets.filter((t) => t._classified === 'ENH');
68
+ const unclearTickets = tickets.filter((t) => t._classified === 'UNCLEAR');
69
+
70
+ console.log(`\n BUG: ${bugTickets.length} ENH: ${enhTickets.length} UNCLEAR: ${unclearTickets.length}\n`);
71
+
72
+ const PAGE_SIZE = 4;
73
+
74
+ async function triagePage(pageTickets, labelPrefix) {
75
+ for (let offset = 0; offset < pageTickets.length; offset += PAGE_SIZE) {
76
+ const page = pageTickets.slice(offset, offset + PAGE_SIZE);
77
+ const pageNum = Math.floor(offset / PAGE_SIZE) + 1;
78
+ const totalPages = Math.ceil(pageTickets.length / PAGE_SIZE);
79
+
80
+ const question = totalPages > 1
81
+ ? `${labelPrefix} — Page ${pageNum}/${totalPages}: select tickets to ACCEPT`
82
+ : `${labelPrefix}: select tickets to ACCEPT`;
83
+
84
+ const options = page.map((t) => ({
85
+ label: `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 55)}`,
86
+ description: truncate(t.description, 100),
87
+ }));
88
+
89
+ const answer = await askUserQuestionFn(question, options, true);
90
+ const selectedLabels = Array.isArray(answer) ? answer : (answer ? [answer] : []);
91
+
92
+ for (const t of page) {
93
+ const label = `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 55)}`;
94
+ if (selectedLabels.includes(label)) {
95
+ accepted.push(t);
96
+ } else {
97
+ declined.push(t);
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ if (bugTickets.length > 0) await triagePage(bugTickets, 'BUG tickets');
104
+ if (enhTickets.length > 0) await triagePage(enhTickets, 'ENH tickets');
105
+
106
+ for (const t of unclearTickets) {
107
+ const answer = await askUserQuestionFn(
108
+ `UNCLEAR ticket #${t.id || t._source_row} — "${truncate(t.title, 60)}": how to handle?`,
109
+ [
110
+ { label: 'Accept as BUG', description: 'Create a BUG request' },
111
+ { label: 'Accept as ENH', description: 'Create an ENH request' },
112
+ { label: 'Decline', description: 'Skip this ticket' },
113
+ ],
114
+ false,
115
+ );
116
+ if (answer === 'Accept as BUG') {
117
+ t._classified = 'BUG';
118
+ accepted.push(t);
119
+ } else if (answer === 'Accept as ENH') {
120
+ t._classified = 'ENH';
121
+ accepted.push(t);
122
+ } else {
123
+ declined.push(t);
124
+ }
125
+ }
126
+
127
+ const acceptedWithIds = [];
128
+ for (const t of accepted) {
129
+ const { id } = createRequestFile(t, t._classified, channel, projectRoot);
130
+ acceptedWithIds.push({ ticket: t, request_id: id });
131
+ console.log(` ✓ Created ${id} — ${t.title}`);
132
+ }
133
+
134
+ const declinedWithReasons = [];
135
+ for (const t of declined) {
136
+ const reason = await askUserQuestionFn(
137
+ `Reason for declining #${t.id || t._source_row} — "${truncate(t.title, 50)}"?`,
138
+ DECLINE_REASONS.map((r) => ({ label: r, description: '' })),
139
+ false,
140
+ );
141
+ declinedWithReasons.push({ ticket: t, reason: reason || 'No reason provided' });
142
+ }
143
+
144
+ return {
145
+ accepted: acceptedWithIds,
146
+ declined: declinedWithReasons,
147
+ unclear,
148
+ };
149
+ }
150
+
151
+ module.exports = { runTriage, nextRequestNumber };
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // CSV write-back
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function parseCsvLine(line, delimiter) {
12
+ const result = [];
13
+ let current = '';
14
+ let inQuotes = false;
15
+ for (let i = 0; i < line.length; i++) {
16
+ const ch = line[i];
17
+ if (ch === '"') {
18
+ if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
19
+ else inQuotes = !inQuotes;
20
+ } else if (ch === delimiter && !inQuotes) {
21
+ result.push(current); current = '';
22
+ } else {
23
+ current += ch;
24
+ }
25
+ }
26
+ result.push(current);
27
+ return result;
28
+ }
29
+
30
+ function csvCell(value) {
31
+ const s = String(value || '');
32
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
33
+ return `"${s.replace(/"/g, '""')}"`;
34
+ }
35
+ return s;
36
+ }
37
+
38
+ function buildTicketDecisions(triageResult) {
39
+ const decisions = {};
40
+ for (const { ticket, request_id } of triageResult.accepted) {
41
+ decisions[ticket._source_row] = { vp_status: 'accepted', vp_comment: '', vp_request_id: request_id };
42
+ }
43
+ for (const { ticket, reason } of triageResult.declined) {
44
+ decisions[ticket._source_row] = { vp_status: 'declined', vp_comment: reason, vp_request_id: '' };
45
+ }
46
+ for (const { ticket } of (triageResult.unclear || [])) {
47
+ decisions[ticket._source_row] = { vp_status: 'unclear', vp_comment: '', vp_request_id: '' };
48
+ }
49
+ return decisions;
50
+ }
51
+
52
+ async function writebackCsv(channel, triageResult, projectRoot) {
53
+ const filePath = path.resolve(projectRoot || process.cwd(), channel.path);
54
+ if (!fs.existsSync(filePath)) {
55
+ return { success: false, error: `CSV file not found: ${filePath}` };
56
+ }
57
+
58
+ try {
59
+ const ext = path.extname(filePath).toLowerCase();
60
+ const delimiter = ext === '.tsv' ? '\t' : ',';
61
+ const content = fs.readFileSync(filePath, 'utf8');
62
+ const lines = content.split(/\r?\n/);
63
+ if (lines.length === 0) return { success: true };
64
+
65
+ let headers = parseCsvLine(lines[0], delimiter);
66
+ const VP_COLS = ['VP_Status', 'VP_Comment', 'VP_RequestID'];
67
+ const existingVpIdx = VP_COLS.map((col) => headers.indexOf(col));
68
+ const hasVpCols = existingVpIdx.every((i) => i >= 0);
69
+
70
+ if (!hasVpCols) {
71
+ headers = [...headers, ...VP_COLS];
72
+ }
73
+
74
+ const decisions = buildTicketDecisions(triageResult);
75
+ const outputLines = [headers.map(csvCell).join(delimiter)];
76
+
77
+ for (let i = 1; i < lines.length; i++) {
78
+ if (!lines[i].trim()) { outputLines.push(''); continue; }
79
+ const row = parseCsvLine(lines[i], delimiter);
80
+ const d = decisions[i];
81
+ if (d) {
82
+ while (row.length < headers.length) row.push('');
83
+ const statusIdx = headers.indexOf('VP_Status');
84
+ const commentIdx = headers.indexOf('VP_Comment');
85
+ const reqIdx = headers.indexOf('VP_RequestID');
86
+ if (statusIdx >= 0) row[statusIdx] = d.vp_status;
87
+ if (commentIdx >= 0) row[commentIdx] = d.vp_comment;
88
+ if (reqIdx >= 0) row[reqIdx] = d.vp_request_id;
89
+ }
90
+ outputLines.push(row.map(csvCell).join(delimiter));
91
+ }
92
+
93
+ fs.writeFileSync(filePath, outputLines.join('\n'), 'utf8');
94
+ return { success: true };
95
+ } catch (e) {
96
+ return { success: false, error: e.message };
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Google Sheets write-back (batchUpdate)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function httpsRequest(method, url, body, headers) {
105
+ return new Promise((resolve, reject) => {
106
+ const urlObj = new URL(url);
107
+ const payload = body ? JSON.stringify(body) : '';
108
+ const req = https.request({
109
+ hostname: urlObj.hostname,
110
+ path: urlObj.pathname + urlObj.search,
111
+ method,
112
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
113
+ }, (res) => {
114
+ let data = '';
115
+ res.on('data', (chunk) => { data += chunk; });
116
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
117
+ });
118
+ req.on('error', reject);
119
+ if (payload) req.write(payload);
120
+ req.end();
121
+ });
122
+ }
123
+
124
+ function colLetterToIndex(letter) {
125
+ let index = 0;
126
+ for (const ch of letter.toUpperCase()) index = index * 26 + ch.charCodeAt(0) - 64;
127
+ return index - 1;
128
+ }
129
+
130
+ function indexToColLetter(idx) {
131
+ let letter = '';
132
+ idx++;
133
+ while (idx > 0) {
134
+ const rem = (idx - 1) % 26;
135
+ letter = String.fromCharCode(65 + rem) + letter;
136
+ idx = Math.floor((idx - 1) / 26);
137
+ }
138
+ return letter;
139
+ }
140
+
141
+ async function getGoogleAccessToken(projectRoot) {
142
+ const credPath = path.join(projectRoot, '.viepilot', '.credentials', 'google-service-account.json');
143
+ if (!fs.existsSync(credPath)) return null;
144
+ const keyJson = JSON.parse(fs.readFileSync(credPath, 'utf8'));
145
+ const now = Math.floor(Date.now() / 1000);
146
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
147
+ const payload = Buffer.from(JSON.stringify({
148
+ iss: keyJson.client_email,
149
+ scope: 'https://www.googleapis.com/auth/spreadsheets',
150
+ aud: 'https://oauth2.googleapis.com/token',
151
+ exp: now + 3600, iat: now,
152
+ })).toString('base64url');
153
+ const crypto = require('crypto');
154
+ const sign = crypto.createSign('RSA-SHA256');
155
+ sign.update(`${header}.${payload}`);
156
+ const sig = sign.sign(keyJson.private_key, 'base64url');
157
+ const jwt = `${header}.${payload}.${sig}`;
158
+ const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`;
159
+ const res = await httpsRequest('POST', 'https://oauth2.googleapis.com/token', null, {
160
+ 'Content-Type': 'application/x-www-form-urlencoded',
161
+ 'Content-Length': Buffer.byteLength(body),
162
+ });
163
+ return res.access_token || null;
164
+ }
165
+
166
+ async function writebackGoogleSheets(channel, triageResult, projectRoot) {
167
+ try {
168
+ const root = projectRoot || process.cwd();
169
+ const token = await getGoogleAccessToken(root);
170
+ if (!token) return { success: false, error: 'No Google credentials for write-back' };
171
+
172
+ const decisions = buildTicketDecisions(triageResult);
173
+ const allTickets = [
174
+ ...triageResult.accepted.map((a) => a.ticket),
175
+ ...triageResult.declined.map((d) => d.ticket),
176
+ ];
177
+ if (allTickets.length === 0) return { success: true };
178
+
179
+ const colMap = channel.column_map;
180
+ const lastCol = colLetterToIndex(Object.values(colMap).sort().pop() || 'F');
181
+ const vpStatusCol = indexToColLetter(lastCol + 1);
182
+ const vpCommentCol = indexToColLetter(lastCol + 2);
183
+ const vpReqCol = indexToColLetter(lastCol + 3);
184
+
185
+ const valueRanges = [];
186
+ for (const ticket of allTickets) {
187
+ const d = decisions[ticket._source_row];
188
+ if (!d) continue;
189
+ const rowNum = ticket._source_row + 1;
190
+ const sheetName = channel.sheet_name || 'Sheet1';
191
+ valueRanges.push({ range: `${sheetName}!${vpStatusCol}${rowNum}`, values: [[d.vp_status]] });
192
+ valueRanges.push({ range: `${sheetName}!${vpCommentCol}${rowNum}`, values: [[d.vp_comment]] });
193
+ valueRanges.push({ range: `${sheetName}!${vpReqCol}${rowNum}`, values: [[d.vp_request_id]] });
194
+ }
195
+
196
+ const url = `https://sheets.googleapis.com/v4/spreadsheets/${channel.spreadsheet_id}/values:batchUpdate`;
197
+ await httpsRequest('POST', url, { valueInputOption: 'RAW', data: valueRanges }, { Authorization: `Bearer ${token}` });
198
+ return { success: true };
199
+ } catch (e) {
200
+ return { success: false, error: e.message };
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Main writeback dispatcher
206
+ // ---------------------------------------------------------------------------
207
+
208
+ async function writeback(channel, triageResult, projectRoot) {
209
+ const root = projectRoot || process.cwd();
210
+ let result;
211
+
212
+ if (channel.type === 'csv') {
213
+ result = await writebackCsv(channel, triageResult, root);
214
+ } else if (channel.type === 'google_sheets') {
215
+ result = await writebackGoogleSheets(channel, triageResult, root);
216
+ } else if (channel.type === 'excel_m365') {
217
+ result = { success: false, error: 'Excel/M365 write-back requires Graph API — see task 123.4' };
218
+ } else {
219
+ result = { success: false, error: `Unknown channel type: ${channel.type}` };
220
+ }
221
+
222
+ if (!result.success) {
223
+ console.warn(` ⚠ Write-back warning: ${result.error} — triage report is the source of truth`);
224
+ }
225
+ return result;
226
+ }
227
+
228
+ module.exports = { writeback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viepilot",
3
- "version": "2.47.0",
3
+ "version": "2.48.0",
4
4
  "description": "**Autonomous Vibe Coding Framework / Bộ khung phát triển tự động có kiểm soát**",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,236 @@
1
+ ---
2
+ name: vp-intake
3
+ description: "Import and triage tickets from Excel/M365 Online, Google Sheets, or CSV/TSV files — classify as BUG/ENH, accept/decline via AskUserQuestion, write back to source, generate TRIAGE report"
4
+ version: 1.0.0
5
+ ---
6
+
7
+ <greeting>
8
+ ## Invocation Banner
9
+
10
+ Output this banner as the **first** thing on every invocation — before questions, work, or any other output:
11
+
12
+ ```
13
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14
+ VIEPILOT ► VP-INTAKE v1.0.0 (fw 2.48.0)
15
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16
+ ```
17
+ </greeting>
18
+ <version_check>
19
+ ## Version Update Check (ENH-072)
20
+
21
+ After displaying the greeting banner, run:
22
+ ```bash
23
+ node "$HOME/.claude/viepilot/bin/vp-tools.cjs" check-update --silent
24
+ ```
25
+
26
+ **If exit code = 1** (update available — new version printed to stdout):
27
+ Display the update notice banner before continuing. Silent otherwise.
28
+ </version_check>
29
+ <persona_context>
30
+ ## Persona Context Injection (ENH-073)
31
+ At skill start, run:
32
+ ```bash
33
+ node "$HOME/.claude/viepilot/bin/vp-tools.cjs" persona auto-switch
34
+ node "$HOME/.claude/viepilot/bin/vp-tools.cjs" persona context
35
+ ```
36
+ Inject the output as `## User Persona` context before any task execution.
37
+ Silent if command unavailable or errors.
38
+ </persona_context>
39
+
40
+ <cursor_skill_adapter>
41
+ ## A. Skill Invocation
42
+ - Skill được gọi khi user mention `vp-intake`, `/vp-intake`, "import tickets", "nhập ticket", "đọc ticket từ", "triage ticket"
43
+ - Treat all user text after the skill mention as `{{VP_ARGS}}`
44
+
45
+ ## B. User Prompting
46
+ Prompt user conversationally with options.
47
+
48
+ ## C. Tool Usage
49
+ Use Cursor tools: `Shell`, `ReadFile`, `Glob`, `rg`, `ApplyPatch`, `WebSearch`, `WebFetch`, `Subagent`
50
+ </cursor_skill_adapter>
51
+ <scope_policy>
52
+ ## ViePilot Namespace Guard (BUG-004)
53
+ - Default mode: only use and reference `vp-*` skills in ViePilot workflows.
54
+ - External skills (`non vp-*`) are out of framework scope unless user explicitly opts in.
55
+ - If external skills appear in runtime context, ignore them and route with the closest built-in `vp-*` skill.
56
+ </scope_policy>
57
+ <implementation_routing_guard>
58
+ ## Implementation routing guard (ENH-021)
59
+
60
+ - This skill **reads, classifies, and triages** external tickets — does not implement code.
61
+ - Accepted tickets create `.viepilot/requests/` files for planning via **`/vp-evolve`** → **`/vp-auto`**.
62
+ - **Exception:** User **explicit** bypass — state clearly in chat.
63
+ </implementation_routing_guard>
64
+
65
+ <objective>
66
+ Import tickets from external sources (Excel/Microsoft 365 Online, Google Sheets, CSV/TSV files),
67
+ classify them automatically as BUG/ENH/UNCLEAR, let the user triage each ticket via
68
+ AskUserQuestion (multi-select), write decisions back to the source, and generate a TRIAGE
69
+ session report.
70
+
71
+ **Creates/Updates:**
72
+ - `.viepilot/requests/BUG-N.md` or `ENH-N.md` for accepted tickets
73
+ - Source file: `VP_Status`, `VP_Comment`, `VP_RequestID` columns updated
74
+ - `.viepilot/intake/TRIAGE-{timestamp}.md` — session report
75
+ </objective>
76
+
77
+ <context>
78
+ Optional flags:
79
+ - `--channel <id>` : Skip channel selection, use this channel ID directly
80
+ - `--dry-run` : Classify and show tickets without creating requests or writing back
81
+
82
+ **Supported channel types:**
83
+ | Type | Auth | Config field |
84
+ |------|------|-------------|
85
+ | `csv` | None | `path` (local file) |
86
+ | `google_sheets` | Service Account JSON | `spreadsheet_id` + `sheet_name` |
87
+ | `excel_m365` | Azure App Registration | `workbook_id` + `sheet_name` |
88
+
89
+ **Config file:** `.viepilot/intake/channels.json`
90
+ **Credentials dir:** `.viepilot/.credentials/` (gitignored)
91
+ </context>
92
+
93
+ <process>
94
+
95
+ ### Step 1: Init intake directory
96
+
97
+ ```bash
98
+ node "$HOME/.claude/viepilot/bin/vp-tools.cjs" intake-init
99
+ ```
100
+
101
+ This creates `.viepilot/intake/channels.json` (scaffold) and `.viepilot/.credentials/` if missing.
102
+
103
+ ### Step 2: Load channels
104
+
105
+ Read `.viepilot/intake/channels.json`. If no channels configured (only example stubs remain),
106
+ tell the user:
107
+
108
+ ```
109
+ No channels configured yet.
110
+ Edit .viepilot/intake/channels.json to add your ticket sources.
111
+ Run vp-tools intake-init to see the config scaffold.
112
+ ```
113
+
114
+ ### Step 3: Select channel (AUQ single-select)
115
+
116
+ ```
117
+ question: "Which ticket channel do you want to import from?"
118
+ header: "Channel"
119
+ options: one per channel — label: "{channel.name} ({channel.type})", description: "{channel.id}"
120
+ ```
121
+
122
+ ### Step 4: Read and classify tickets
123
+
124
+ Dispatch to the correct adapter based on `channel.type`:
125
+ - `csv` → `lib/intake/adapters/csv.cjs` → `readCsv(channel, projectRoot)`
126
+ - `google_sheets` → `lib/intake/adapters/google-sheets.cjs` → `readGoogleSheet(channel, projectRoot)`
127
+ - `excel_m365` → `lib/intake/adapters/excel-m365.cjs` → `readExcelM365(channel, projectRoot)`
128
+
129
+ For each ticket, call `classifyTicket(ticket)` from `lib/intake/classifier.cjs`.
130
+ Attach `ticket._classified = 'BUG' | 'ENH' | 'UNCLEAR'`.
131
+
132
+ Display classification summary:
133
+ ```
134
+ Read {N} tickets from {channel.name}
135
+ BUG: {N} ENH: {N} UNCLEAR: {N}
136
+ ```
137
+
138
+ If 0 tickets found, exit with message "No tickets found in this channel."
139
+
140
+ ### Step 5: Triage (AUQ multi-select)
141
+
142
+ Call `runTriage(tickets, channel, projectRoot, askFn)` from `lib/intake/triage-ux.cjs`.
143
+
144
+ `askFn` is a wrapper around `AskUserQuestion`:
145
+ ```js
146
+ async function askFn(question, options, multiSelect) {
147
+ // calls AskUserQuestion tool → returns selected label(s)
148
+ }
149
+ ```
150
+
151
+ For each ticket: AUQ multi-select to accept/decline, then AUQ single-select for decline reason.
152
+ UNCLEAR tickets get a 3-choice prompt: "Accept as BUG / Accept as ENH / Decline".
153
+
154
+ ### Step 6: Write-back + Report
155
+
156
+ ```js
157
+ await writeback(channel, triageResult, projectRoot); // lib/intake/writeback.cjs
158
+ const reportPath = generateTriageReport(channel, triageResult, projectRoot); // lib/intake/report.cjs
159
+ ```
160
+
161
+ Write-back failure → warn (non-fatal), report is still generated.
162
+
163
+ ### Step 7: Completion banner
164
+
165
+ ```
166
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
167
+ VIEPILOT ► INTAKE COMPLETE ✓
168
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
169
+
170
+ Channel: {channel.name}
171
+ Accepted: {N} → {request IDs}
172
+ Declined: {N}
173
+ Report: .viepilot/intake/TRIAGE-{timestamp}.md
174
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
175
+ ```
176
+
177
+ ### Step 8: Next action (AUQ single-select)
178
+
179
+ ```
180
+ question: "Triage complete. What would you like to do next?"
181
+ options:
182
+ - "Execute accepted requests → /vp-auto" (Recommended)
183
+ - "Plan phase/tasks → /vp-evolve"
184
+ - "Import from another channel → /vp-intake"
185
+ - "Done for now"
186
+ ```
187
+
188
+ </process>
189
+
190
+ <success_criteria>
191
+ - [ ] Channels loaded from `.viepilot/intake/channels.json`
192
+ - [ ] Correct adapter dispatched based on channel type
193
+ - [ ] Tickets classified as BUG / ENH / UNCLEAR
194
+ - [ ] AUQ multi-select triage completed with accept/decline per ticket
195
+ - [ ] Decline reasons collected and attached
196
+ - [ ] Accepted tickets auto-create `.viepilot/requests/` files
197
+ - [ ] Write-back updates source (non-fatal on failure)
198
+ - [ ] TRIAGE session report generated at `.viepilot/intake/TRIAGE-{timestamp}.md`
199
+ </success_criteria>
200
+
201
+ ## Adapter Compatibility
202
+
203
+ ### AskUserQuestion Tool (ENH-059)
204
+
205
+ | Adapter | Interactive Prompts | Notes |
206
+ |---------|---------------------|-------|
207
+ | Claude Code (terminal) | ✅ `AskUserQuestion` — REQUIRED | Preload via ToolSearch before first call |
208
+ | Cursor (Agent/Skills) | ❌ Text fallback | Plain numbered list |
209
+ | Codex CLI | ❌ Text fallback | N/A |
210
+ | Antigravity | ❌ Text fallback | N/A |
211
+ | GitHub Copilot | ✅ Text fallback | Via `.agent.md` |
212
+
213
+ **Prompts in this skill:**
214
+ - Channel selection (Step 3)
215
+ - Ticket accept/decline multi-select (Step 5)
216
+ - Decline reason (Step 5)
217
+ - UNCLEAR handling (Step 5)
218
+ - Next action (Step 8)
219
+
220
+ ## Capabilities
221
+ - Read tickets from Excel/Microsoft 365 Online via Microsoft Graph API
222
+ - Read tickets from Google Sheets via Sheets API v4
223
+ - Read tickets from local CSV/TSV files
224
+ - Auto-classify tickets as BUG/ENH/UNCLEAR with Vietnamese keyword support
225
+ - Interactive triage with AskUserQuestion (multi-select, paginated)
226
+ - Write-back VP_Status/VP_Comment/VP_RequestID to source
227
+ - Generate TRIAGE session report in .viepilot/intake/
228
+
229
+ ## Tags
230
+ intake, tickets, triage, excel, google-sheets, csv, bug-import, enh-import, external-sources
231
+
232
+ ## Best Practices
233
+ - Always run `intake-init` before first use to scaffold the config
234
+ - Store credentials in `.viepilot/.credentials/` — never commit them
235
+ - Review the TRIAGE report after each session for audit trail
236
+ - Use `--dry-run` to preview classification before creating requests