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.
- package/CHANGELOG.md +234 -0
- package/README.md +1 -1
- package/bin/viepilot.cjs +1 -0
- package/bin/vp-tools.cjs +123 -1
- package/docs/brainstorm/session-2026-05-22.md +472 -0
- package/docs/dev/agents.md +51 -41
- package/lib/adapter-context.cjs +294 -0
- package/lib/adapters/antigravity.cjs +8 -2
- package/lib/adapters/claude-code.cjs +4 -0
- package/lib/audit/browser-runner.cjs +102 -0
- package/lib/intake/adapters/browser.cjs +58 -0
- package/lib/intake/adapters/excel-m365.cjs +114 -29
- package/lib/intake/auto-intake.cjs +194 -0
- package/lib/intake/channels.cjs +44 -3
- package/lib/intake/classifier.cjs +22 -4
- package/lib/intake/manifest.cjs +81 -0
- package/lib/intake/setup-wizard.cjs +215 -0
- package/lib/intake/triage-ux.cjs +10 -2
- package/lib/intake/validator.cjs +97 -0
- package/lib/intake/writeback.cjs +169 -3
- package/lib/request/url-enricher.cjs +69 -0
- package/lib/viepilot-install.cjs +15 -0
- package/package.json +1 -1
- package/skills/vp-audit/SKILL.md +99 -3
- package/skills/vp-auto/SKILL.md +54 -4
- package/skills/vp-brainstorm/SKILL.md +69 -3
- package/skills/vp-crystallize/SKILL.md +52 -3
- package/skills/vp-debug/SKILL.md +52 -3
- package/skills/vp-design/SKILL.md +52 -3
- package/skills/vp-docs/SKILL.md +52 -3
- package/skills/vp-evolve/SKILL.md +52 -3
- package/skills/vp-info/SKILL.md +52 -3
- package/skills/vp-intake/SKILL.md +349 -14
- package/skills/vp-pause/SKILL.md +52 -3
- package/skills/vp-persona/SKILL.md +52 -3
- package/skills/vp-proposal/SKILL.md +52 -3
- package/skills/vp-request/SKILL.md +72 -3
- package/skills/vp-resume/SKILL.md +52 -3
- package/skills/vp-rollback/SKILL.md +52 -3
- package/skills/vp-skills/SKILL.md +52 -3
- package/skills/vp-status/SKILL.md +52 -3
- package/skills/vp-task/SKILL.md +52 -3
- package/skills/vp-ui-components/SKILL.md +52 -3
- package/skills/vp-update/SKILL.md +52 -3
- package/workflows/autonomous.md +268 -18
- package/workflows/brainstorm.md +222 -7
- package/workflows/crystallize.md +124 -6
- package/workflows/design.md +62 -1
- 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 };
|
package/lib/intake/triage-ux.cjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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 };
|
package/lib/intake/writeback.cjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 };
|
package/lib/viepilot-install.cjs
CHANGED
|
@@ -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
|
|