viepilot 2.49.0 → 3.7.2

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 +234 -0
  2. package/README.md +1 -1
  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/lib/adapter-context.cjs +294 -0
  8. package/lib/adapters/antigravity.cjs +8 -2
  9. package/lib/adapters/claude-code.cjs +4 -0
  10. package/lib/audit/browser-runner.cjs +102 -0
  11. package/lib/intake/adapters/browser.cjs +58 -0
  12. package/lib/intake/adapters/excel-m365.cjs +114 -29
  13. package/lib/intake/auto-intake.cjs +194 -0
  14. package/lib/intake/channels.cjs +44 -3
  15. package/lib/intake/classifier.cjs +22 -4
  16. package/lib/intake/manifest.cjs +81 -0
  17. package/lib/intake/setup-wizard.cjs +215 -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 +349 -14
  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
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { appendChannel, initIntakeDir } = require('./channels.cjs');
5
+ const { isSharingLink } = require('./adapters/excel-m365.cjs');
6
+
7
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
8
+
9
+ function slugify(name) {
10
+ return name
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '');
14
+ }
15
+
16
+ function extractSpreadsheetId(urlOrId) {
17
+ const match = urlOrId.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
18
+ return match ? match[1] : urlOrId.trim();
19
+ }
20
+
21
+ function printCredentialsGuide(type) {
22
+ if (type === 'google_sheets') {
23
+ return [
24
+ '',
25
+ ' Google Sheets — Service Account setup:',
26
+ ' 1. Go to console.cloud.google.com → IAM & Admin → Service Accounts → Create',
27
+ ' 2. Create a key (JSON) → download it',
28
+ ' 3. Share your Google Sheet with the service account email',
29
+ ' 4. Save the JSON key to: .viepilot/.credentials/google-service-account.json',
30
+ '',
31
+ ].join('\n');
32
+ }
33
+ if (type === 'excel_m365') {
34
+ return [
35
+ '',
36
+ ' Excel M365 — Azure App Registration:',
37
+ ' 1. Go to portal.azure.com → Azure Active Directory → App Registrations → New',
38
+ ' 2. Add API permission: Microsoft Graph → Files.Read.All (Application)',
39
+ ' 3. Grant admin consent → Certificates & Secrets → New client secret',
40
+ ' 4. Save to: .viepilot/.credentials/m365-credentials.json',
41
+ ' { "tenant_id": "...", "client_id": "...", "client_secret": "..." }',
42
+ '',
43
+ ].join('\n');
44
+ }
45
+ return '';
46
+ }
47
+
48
+ // ─── Per-type config collectors ───────────────────────────────────────────────
49
+
50
+ async function collectCsvConfig(name, id, askFn) {
51
+ const filePath = await askFn('Đường dẫn file CSV/TSV? (relative to project root, vd: ./reports/tickets.csv)', null, false);
52
+ const titleCol = await askFn('Header tên cột TITLE là gì? (vd: summary)', null, false);
53
+ const descCol = await askFn('Header tên cột DESCRIPTION là gì? (vd: details)', null, false);
54
+ const repCol = await askFn('Header tên cột REPORTER? (để trống nếu không có)', null, false);
55
+ const dateCol = await askFn('Header tên cột DATE? (để trống nếu không có)', null, false);
56
+
57
+ const column_map = { title: titleCol, description: descCol };
58
+ if (repCol) column_map.reporter = repCol;
59
+ if (dateCol) column_map.date = dateCol;
60
+
61
+ return { id, type: 'csv', name, path: filePath, column_map };
62
+ }
63
+
64
+ async function collectGSheetsConfig(name, id, askFn) {
65
+ const rawId = await askFn('Spreadsheet ID hoặc URL Google Sheets?', null, false);
66
+ const spreadId = extractSpreadsheetId(rawId);
67
+ const sheetName = await askFn('Sheet name? (mặc định: Sheet1 — để trống dùng Sheet1)', null, false) || 'Sheet1';
68
+ const titleCol = await askFn('Cột TITLE ở cột letter nào? (vd: B)', null, false);
69
+ const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
70
+ const repCol = await askFn('Cột REPORTER? (optional, để trống nếu không có)', null, false);
71
+ const dateCol = await askFn('Cột DATE? (optional)', null, false);
72
+
73
+ const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
74
+ if (repCol) column_map.reporter = repCol.toUpperCase();
75
+ if (dateCol) column_map.date = dateCol.toUpperCase();
76
+
77
+ return { id, type: 'google_sheets', name, spreadsheet_id: spreadId, sheet_name: sheetName, column_map };
78
+ }
79
+
80
+ async function collectM365Config(name, id, askFn) {
81
+ const workbookId = await askFn('Workbook ID? (từ SharePoint URL, dạng item ID)', null, false);
82
+ const sheetName = await askFn('Sheet name? (mặc định: Sheet1)', null, false) || 'Sheet1';
83
+ const titleCol = await askFn('Cột TITLE? (letter, vd: B)', null, false);
84
+ const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
85
+ const repCol = await askFn('Cột REPORTER? (optional)', null, false);
86
+ const dateCol = await askFn('Cột DATE? (optional)', null, false);
87
+
88
+ const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
89
+ if (repCol) column_map.reporter = repCol.toUpperCase();
90
+ if (dateCol) column_map.date = dateCol.toUpperCase();
91
+
92
+ return { id, type: 'excel_m365', name, workbook_id: workbookId, sheet_name: sheetName, column_map };
93
+ }
94
+
95
+ async function collectSharePointConfig(name, id, askFn) {
96
+ let sharingUrl = '';
97
+ while (!isSharingLink(sharingUrl)) {
98
+ sharingUrl = await askFn(
99
+ 'SharePoint sharing URL? (dạng https://xxx.sharepoint.com/:x:/g/... hoặc /:x:/r/...)',
100
+ null,
101
+ false
102
+ );
103
+ if (!isSharingLink(sharingUrl)) {
104
+ process.stdout.write(' ⚠ URL không đúng định dạng sharing link. Vui lòng dán đúng URL chia sẻ.\n');
105
+ }
106
+ }
107
+
108
+ const sheetName = await askFn('Sheet name? (để trống = dùng sheet đầu tiên)', null, false) || '';
109
+ const titleCol = await askFn('Cột TITLE? (letter, vd: B)', null, false);
110
+ const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
111
+ const repCol = await askFn('Cột REPORTER? (optional)', null, false);
112
+ const dateCol = await askFn('Cột DATE? (optional)', null, false);
113
+
114
+ const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
115
+ if (repCol) column_map.reporter = repCol.toUpperCase();
116
+ if (dateCol) column_map.date = dateCol.toUpperCase();
117
+
118
+ const ch = { id, type: 'excel_m365', name, sharing_url: sharingUrl, column_map };
119
+ if (sheetName) ch.sheet_name = sheetName;
120
+ return ch;
121
+ }
122
+
123
+ // ─── Single channel wizard ────────────────────────────────────────────────────
124
+
125
+ const CHANNEL_TYPE_OPTIONS = [
126
+ { label: 'CSV / TSV file (local)', description: 'Đọc từ file .csv hoặc .tsv trên máy' },
127
+ { label: 'Google Sheets', description: 'Đọc từ Google Sheets qua Service Account' },
128
+ { label: 'Excel M365 — Graph API (Azure App)', description: 'Đọc từ OneDrive/SharePoint qua Microsoft Graph API' },
129
+ { label: 'SharePoint Sharing Link (no credentials)', description: 'Dán link chia sẻ SharePoint — không cần cấu hình OAuth' },
130
+ ];
131
+
132
+ async function wizardOneChannel(projectRoot, askFn) {
133
+ // Step 1 — loại channel
134
+ const typeChoice = await askFn(
135
+ 'Bạn muốn cấu hình loại nguồn ticket nào?',
136
+ CHANNEL_TYPE_OPTIONS,
137
+ false
138
+ );
139
+
140
+ // Step 2 — display name → id
141
+ const name = await askFn('Tên hiển thị cho channel này? (vd: UAT Tickets Q2)', null, false);
142
+ const id = slugify(name) || `channel-${Date.now()}`;
143
+
144
+ // Step 3 — collect per-type fields
145
+ let channelObj;
146
+ if (typeChoice === 'CSV / TSV file (local)') {
147
+ channelObj = await collectCsvConfig(name, id, askFn);
148
+ } else if (typeChoice === 'Google Sheets') {
149
+ process.stdout.write(printCredentialsGuide('google_sheets'));
150
+ channelObj = await collectGSheetsConfig(name, id, askFn);
151
+ } else if (typeChoice === 'Excel M365 — Graph API (Azure App)') {
152
+ process.stdout.write(printCredentialsGuide('excel_m365'));
153
+ channelObj = await collectM365Config(name, id, askFn);
154
+ } else {
155
+ // SharePoint Sharing Link
156
+ channelObj = await collectSharePointConfig(name, id, askFn);
157
+ }
158
+
159
+ // Step 4 — preview + confirm
160
+ process.stdout.write('\n Preview cấu hình:\n');
161
+ process.stdout.write(JSON.stringify(channelObj, null, 2).split('\n').map(l => ' ' + l).join('\n'));
162
+ process.stdout.write('\n');
163
+
164
+ const confirm = await askFn('Lưu cấu hình này?', [
165
+ { label: 'Lưu', description: 'Ghi channel vào channels.json' },
166
+ { label: 'Cấu hình lại', description: 'Nhập lại thông tin channel này' },
167
+ { label: 'Huỷ', description: 'Bỏ qua channel này' },
168
+ ], false);
169
+
170
+ if (confirm === 'Cấu hình lại') {
171
+ return wizardOneChannel(projectRoot, askFn);
172
+ }
173
+ if (confirm === 'Huỷ') {
174
+ return null;
175
+ }
176
+
177
+ // Step 5 — write
178
+ appendChannel(projectRoot, channelObj);
179
+ process.stdout.write(`\n ✓ Channel "${name}" đã được lưu vào .viepilot/intake/channels.json\n\n`);
180
+ return channelObj;
181
+ }
182
+
183
+ // ─── Public entry point ───────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Run the interactive setup wizard.
187
+ * @param {string} projectRoot
188
+ * @param {Function} askFn async (question, options, multiSelect) → string
189
+ * @returns {object|null} last channel created, or null if none
190
+ */
191
+ async function runSetupWizard(projectRoot, askFn) {
192
+ initIntakeDir(projectRoot);
193
+ process.stdout.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
194
+ process.stdout.write(' VP-INTAKE ► Setup Wizard\n');
195
+ process.stdout.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
196
+
197
+ let lastCreated = null;
198
+
199
+ // eslint-disable-next-line no-constant-condition
200
+ while (true) {
201
+ const ch = await wizardOneChannel(projectRoot, askFn);
202
+ if (ch) lastCreated = ch;
203
+
204
+ const more = await askFn('Cấu hình thêm channel khác?', [
205
+ { label: 'Không — tiếp tục import', description: 'Chuyển sang chọn channel và import ticket' },
206
+ { label: 'Có — thêm channel', description: 'Cấu hình thêm một nguồn ticket nữa' },
207
+ ], false);
208
+
209
+ if (more !== 'Có — thêm channel') break;
210
+ }
211
+
212
+ return lastCreated;
213
+ }
214
+
215
+ module.exports = { runSetupWizard, slugify, extractSpreadsheetId };
@@ -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 };
@@ -104,7 +104,7 @@ async function writebackCsv(channel, triageResult, projectRoot) {
104
104
  function httpsRequest(method, url, body, headers) {
105
105
  return new Promise((resolve, reject) => {
106
106
  const urlObj = new URL(url);
107
- const payload = body ? JSON.stringify(body) : '';
107
+ const payload = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : '';
108
108
  const req = https.request({
109
109
  hostname: urlObj.hostname,
110
110
  path: urlObj.pathname + urlObj.search,
@@ -201,6 +201,94 @@ async function writebackGoogleSheets(channel, triageResult, projectRoot) {
201
201
  }
202
202
  }
203
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // Excel M365 write-back (Graph API PATCH)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ async function writebackExcelM365(channel, triageResult, projectRoot) {
209
+ if (channel.sharing_url) {
210
+ return {
211
+ success: false,
212
+ error: `Channel "${channel.id || channel.name}" uses a sharing_url — write-back is read-only for sharing links.\n` +
213
+ `To enable write-back: configure workbook_id + .viepilot/.credentials/m365-credentials.json`,
214
+ };
215
+ }
216
+
217
+ const root = projectRoot || process.cwd();
218
+ const credPath = path.join(root, '.viepilot', '.credentials', 'm365-credentials.json');
219
+ if (!fs.existsSync(credPath)) {
220
+ return {
221
+ success: false,
222
+ error: `m365-credentials.json not found at ${credPath}.\n` +
223
+ `Create it with: { "tenant_id": "...", "client_id": "...", "client_secret": "..." }`,
224
+ };
225
+ }
226
+
227
+ let creds;
228
+ try {
229
+ creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
230
+ } catch (e) {
231
+ return { success: false, error: `Failed to parse m365-credentials.json: ${e.message}` };
232
+ }
233
+
234
+ try {
235
+ const tokenUrl = `https://login.microsoftonline.com/${creds.tenant_id}/oauth2/v2.0/token`;
236
+ const tokenBody = [
237
+ `grant_type=client_credentials`,
238
+ `client_id=${encodeURIComponent(creds.client_id)}`,
239
+ `client_secret=${encodeURIComponent(creds.client_secret)}`,
240
+ `scope=https%3A%2F%2Fgraph.microsoft.com%2F.default`,
241
+ ].join('&');
242
+
243
+ const tokenRes = await httpsRequest('POST', tokenUrl, tokenBody, {
244
+ 'Content-Type': 'application/x-www-form-urlencoded',
245
+ 'Content-Length': Buffer.byteLength(tokenBody),
246
+ });
247
+
248
+ if (!tokenRes.access_token) {
249
+ return { success: false, error: `M365 authentication failed: ${JSON.stringify(tokenRes)}` };
250
+ }
251
+
252
+ const token = tokenRes.access_token;
253
+ const workbookId = channel.workbook_id;
254
+ const sheetName = encodeURIComponent(channel.sheet_name || 'Sheet1');
255
+ const colMap = channel.column_map || {};
256
+ const lastColIdx = Object.values(colMap).length > 0
257
+ ? Math.max(...Object.values(colMap).map(colLetterToIndex))
258
+ : 5;
259
+ const vpStatusCol = indexToColLetter(lastColIdx + 1);
260
+ const vpCommentCol = indexToColLetter(lastColIdx + 2);
261
+ const vpReqCol = indexToColLetter(lastColIdx + 3);
262
+
263
+ const decisions = buildTicketDecisions(triageResult);
264
+ const allTickets = [
265
+ ...triageResult.accepted.map((a) => a.ticket),
266
+ ...triageResult.declined.map((d) => d.ticket),
267
+ ...(triageResult.unclear || []).map((u) => u.ticket),
268
+ ];
269
+
270
+ let updatedCount = 0;
271
+ for (const ticket of allTickets) {
272
+ const d = decisions[ticket._source_row];
273
+ if (!d) continue;
274
+ const rowNum = ticket._source_row + 1;
275
+ const rangeAddress = `${vpStatusCol}${rowNum}:${vpReqCol}${rowNum}`;
276
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${workbookId}/workbook/worksheets/${sheetName}/range(address='${rangeAddress}')`;
277
+ const res = await httpsRequest('PATCH', url, { values: [[d.vp_status, d.vp_comment, d.vp_request_id]] }, {
278
+ Authorization: `Bearer ${token}`,
279
+ });
280
+ if (res.error) {
281
+ return { success: false, error: `Graph API PATCH error: ${res.error.message || JSON.stringify(res.error)}` };
282
+ }
283
+ updatedCount++;
284
+ }
285
+
286
+ return { success: true, updated: updatedCount };
287
+ } catch (e) {
288
+ return { success: false, error: e.message };
289
+ }
290
+ }
291
+
204
292
  // ---------------------------------------------------------------------------
205
293
  // Main writeback dispatcher
206
294
  // ---------------------------------------------------------------------------
@@ -214,7 +302,7 @@ async function writeback(channel, triageResult, projectRoot) {
214
302
  } else if (channel.type === 'google_sheets') {
215
303
  result = await writebackGoogleSheets(channel, triageResult, root);
216
304
  } else if (channel.type === 'excel_m365') {
217
- result = { success: false, error: 'Excel/M365 write-back requires Graph API — see task 123.4' };
305
+ result = await writebackExcelM365(channel, triageResult, root);
218
306
  } else {
219
307
  result = { success: false, error: `Unknown channel type: ${channel.type}` };
220
308
  }
@@ -225,4 +313,82 @@ async function writeback(channel, triageResult, projectRoot) {
225
313
  return result;
226
314
  }
227
315
 
228
- module.exports = { writeback };
316
+ // ---------------------------------------------------------------------------
317
+ // Post-task intake write-back (ENH-095)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * Write task completion status back to the original source row.
322
+ * Called by vp-auto post-PASS hook when a task has an ## Intake Source block.
323
+ *
324
+ * @param {object} channel - Original intake channel config (type, sharing_url or workbook_id)
325
+ * @param {number} sourceRow - 0-based row index (ticket._source_row)
326
+ * @param {object} response - { status, phaseTask, version, date }
327
+ * @param {string} projectRoot
328
+ * @param {string} [sheetName] - Sheet name from manifest
329
+ * @param {string} [responseCol] - Column letter from manifest write_back.response_col
330
+ * @returns {Promise<{success: boolean, cell?: string, text?: string, error?: string}>}
331
+ */
332
+ async function writebackIntakeResponse(channel, sourceRow, response, projectRoot, sheetName, responseCol) {
333
+ const text = [
334
+ response.status || 'Done',
335
+ response.phaseTask || '',
336
+ response.version || '',
337
+ response.date || new Date().toISOString().slice(0, 10),
338
+ ].filter(Boolean).join(' | ');
339
+
340
+ if (!channel || !channel.type) {
341
+ process.stderr.write(`[vp-intake] Write-back skipped: no channel info\n`);
342
+ return { success: false, error: 'no channel info' };
343
+ }
344
+
345
+ if (channel.type === 'excel_m365' && channel.sharing_url) {
346
+ process.stderr.write(`[vp-intake] Write-back skipped for sharing_url channel (read-only). Text: ${text}\n`);
347
+ return { success: false, error: 'sharing_url is read-only — use workbook_id for write-back' };
348
+ }
349
+
350
+ if (channel.type === 'excel_m365' && channel.workbook_id) {
351
+ const root = projectRoot || process.cwd();
352
+ const credPath = path.join(root, '.viepilot', '.credentials', 'm365-credentials.json');
353
+ if (!fs.existsSync(credPath)) {
354
+ process.stderr.write(`[vp-intake] Write-back skipped: m365-credentials.json not found\n`);
355
+ return { success: false, error: 'm365-credentials.json not found' };
356
+ }
357
+ try {
358
+ const creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
359
+ const tokenUrl = `https://login.microsoftonline.com/${creds.tenant_id}/oauth2/v2.0/token`;
360
+ const tokenBody = [
361
+ 'grant_type=client_credentials',
362
+ `client_id=${encodeURIComponent(creds.client_id)}`,
363
+ `client_secret=${encodeURIComponent(creds.client_secret)}`,
364
+ 'scope=https%3A%2F%2Fgraph.microsoft.com%2F.default',
365
+ ].join('&');
366
+ const tokenRes = await httpsRequest('POST', tokenUrl, tokenBody, {
367
+ 'Content-Type': 'application/x-www-form-urlencoded',
368
+ 'Content-Length': Buffer.byteLength(tokenBody),
369
+ });
370
+ if (!tokenRes.access_token) {
371
+ return { success: false, error: `M365 auth failed: ${JSON.stringify(tokenRes)}` };
372
+ }
373
+ const col = responseCol || 'G';
374
+ const sheet = encodeURIComponent(sheetName || channel.sheet_name || 'Sheet1');
375
+ const cell = `${col}${sourceRow + 2}`;
376
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${channel.workbook_id}/workbook/worksheets/${sheet}/range(address='${cell}')`;
377
+ const res = await httpsRequest('PATCH', url, { values: [[text]] }, {
378
+ Authorization: `Bearer ${tokenRes.access_token}`,
379
+ });
380
+ if (res.error) {
381
+ return { success: false, error: `Graph API error: ${res.error.message || JSON.stringify(res.error)}` };
382
+ }
383
+ return { success: true, cell, text };
384
+ } catch (e) {
385
+ return { success: false, error: e.message };
386
+ }
387
+ }
388
+
389
+ // Google Sheets, CSV, browser, unknown: skip silently
390
+ process.stderr.write(`[vp-intake] Write-back skipped for channel type "${channel.type}" (not writable or not yet implemented)\n`);
391
+ return { success: false, error: `channel type "${channel.type}" does not support post-task write-back` };
392
+ }
393
+
394
+ module.exports = { writeback, writebackIntakeResponse };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const URL_ROUTES = {
4
+ github: /github\.com\/[^/]+\/[^/]+\/issues\/\d+/,
5
+ linear: /linear\.app\/[^/]+\/issue\/[A-Z]+-\d+/,
6
+ jira: /atlassian\.net\/browse\/[A-Z]+-\d+/,
7
+ trello: /trello\.com\/c\/[a-zA-Z0-9]+/,
8
+ notion: /notion\.so\/[a-f0-9-]{8,}/,
9
+ };
10
+
11
+ /**
12
+ * Detect whether input string contains a known issue-tracker URL.
13
+ * @param {string} input - raw user input (may contain URL anywhere)
14
+ * @returns {{ matched: boolean, source?: string, url?: string }}
15
+ */
16
+ function detectIssueUrl(input) {
17
+ if (!input || typeof input !== 'string') return { matched: false };
18
+ // Extract first https?:// URL from input
19
+ const urlMatch = input.match(/https?:\/\/[^\s]+/);
20
+ if (!urlMatch) return { matched: false };
21
+ const url = urlMatch[0].replace(/[.,!?)]+$/, ''); // strip trailing punctuation
22
+ for (const [source, pattern] of Object.entries(URL_ROUTES)) {
23
+ if (pattern.test(url)) return { matched: true, source, url };
24
+ }
25
+ return { matched: false };
26
+ }
27
+
28
+ /**
29
+ * Enrich a request with details extracted from an issue URL via browser-intake-agent.
30
+ * Returns null (never throws) when extraction fails or agent is unavailable.
31
+ * CC adapter only — dispatched by vp-request SKILL.md / workflows/request.md.
32
+ * @param {string} url
33
+ * @param {string} source - detected source type
34
+ * @param {string} projectRoot
35
+ * @returns {Promise<{title,type,description,priority,labels}|null>}
36
+ */
37
+ async function enrichFromUrl(url, source, projectRoot) {
38
+ try {
39
+ // CC adapter: Agent dispatch is handled by request.md (orchestrator layer).
40
+ // Direct callers (non-CC) receive null — enrichment silently skipped.
41
+ return null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Format pre-fill display for user confirmation.
49
+ * @param {object} enriched - { title, type, description, priority, labels }
50
+ * @param {string} url
51
+ * @returns {string} formatted display string
52
+ */
53
+ function formatPreFillDisplay(enriched, url) {
54
+ if (!enriched) return '';
55
+ const lines = [
56
+ `Context extracted from: ${url}`,
57
+ ``,
58
+ ` Title: ${enriched.title || '(not detected)'}`,
59
+ ` Type: ${enriched.type || '(not detected)'}`,
60
+ ` Description: ${String(enriched.description || '').slice(0, 120)}${(enriched.description || '').length > 120 ? '...' : ''}`,
61
+ ` Priority: ${enriched.priority || '(not detected)'}`,
62
+ ];
63
+ if (enriched.labels && enriched.labels.length > 0) {
64
+ lines.push(` Labels: ${enriched.labels.join(', ')}`);
65
+ }
66
+ return lines.join('\n');
67
+ }
68
+
69
+ module.exports = { detectIssueUrl, enrichFromUrl, formatPreFillDisplay, URL_ROUTES };
@@ -203,6 +203,21 @@ function buildInstallPlan(packageRoot, envSource = process.env, opts = {}) {
203
203
  steps.push(ent.isDirectory() ? { kind: 'copy_dir', from: src, to: dest } : { kind: 'copy_file', from: src, to: dest });
204
204
  }
205
205
  }
206
+ // BUG-027: copy adapter-native agent definitions to adapter's native agents dir
207
+ // e.g. agents/claude-code/ → ~/.claude/agents/ (not ~/.claude/viepilot/agents/claude-code/)
208
+ if (adapter.claudeAgentsSrc && adapter.claudeAgentsDir) {
209
+ const agentsSrcDir = path.join(root, adapter.claudeAgentsSrc);
210
+ const agentsDestDir = adapter.claudeAgentsDir(home);
211
+ if (fs.existsSync(agentsSrcDir)) {
212
+ steps.push({ kind: 'mkdir', path: agentsDestDir });
213
+ for (const ent of fs.readdirSync(agentsSrcDir, { withFileTypes: true })) {
214
+ const src = path.join(agentsSrcDir, ent.name);
215
+ const dest = path.join(agentsDestDir, ent.name);
216
+ steps.push(ent.isDirectory() ? { kind: 'copy_dir', from: src, to: dest } : { kind: 'copy_file', from: src, to: dest });
217
+ }
218
+ }
219
+ }
220
+
206
221
  // BUG-007: copy package.json so resolveViepilotPackageRoot() finds the root
207
222
  steps.push({ kind: 'copy_file', from: path.join(root, 'package.json'), to: path.join(vpDir, 'package.json') });
208
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viepilot",
3
- "version": "2.49.0",
3
+ "version": "3.7.2",
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": {