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
|
@@ -79,35 +79,55 @@ function httpsGet(url, headers) {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Follow redirects and return {status, headers, body, cookies}.
|
|
84
|
+
* Cookies (Set-Cookie) are accumulated across all redirects and forwarded
|
|
85
|
+
* on each hop — required for SharePoint FedAuth session cookies.
|
|
86
|
+
*/
|
|
87
|
+
function httpsGetRaw(url, headers = {}, maxRedirects = 8) {
|
|
84
88
|
return new Promise((resolve, reject) => {
|
|
85
|
-
function doGet(currentUrl, remaining) {
|
|
89
|
+
function doGet(currentUrl, remaining, accCookies) {
|
|
86
90
|
const urlObj = new URL(currentUrl);
|
|
91
|
+
const reqHeaders = {
|
|
92
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
93
|
+
...headers,
|
|
94
|
+
};
|
|
95
|
+
if (accCookies) reqHeaders['Cookie'] = accCookies;
|
|
96
|
+
|
|
87
97
|
const req = https.request({
|
|
88
98
|
hostname: urlObj.hostname,
|
|
89
99
|
path: urlObj.pathname + urlObj.search,
|
|
90
100
|
method: 'GET',
|
|
91
|
-
headers:
|
|
92
|
-
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
93
|
-
...headers,
|
|
94
|
-
},
|
|
101
|
+
headers: reqHeaders,
|
|
95
102
|
}, (res) => {
|
|
103
|
+
// Accumulate Set-Cookie headers across redirects
|
|
104
|
+
const setCookies = (res.headers['set-cookie'] || []).map((c) => c.split(';')[0]);
|
|
105
|
+
const newCookies = setCookies.length
|
|
106
|
+
? [accCookies, ...setCookies].filter(Boolean).join('; ')
|
|
107
|
+
: accCookies;
|
|
108
|
+
|
|
96
109
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
|
|
97
110
|
let nextUrl = res.headers.location;
|
|
98
111
|
if (!nextUrl.startsWith('http')) {
|
|
99
112
|
nextUrl = `${urlObj.protocol}//${urlObj.host}${nextUrl}`;
|
|
100
113
|
}
|
|
101
|
-
|
|
114
|
+
// Drain response body before following redirect
|
|
115
|
+
res.resume();
|
|
116
|
+
return doGet(nextUrl, remaining - 1, newCookies);
|
|
102
117
|
}
|
|
103
118
|
const chunks = [];
|
|
104
119
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
105
|
-
res.on('end', () => resolve({
|
|
120
|
+
res.on('end', () => resolve({
|
|
121
|
+
status: res.statusCode,
|
|
122
|
+
headers: res.headers,
|
|
123
|
+
body: Buffer.concat(chunks),
|
|
124
|
+
cookies: newCookies,
|
|
125
|
+
}));
|
|
106
126
|
});
|
|
107
127
|
req.on('error', reject);
|
|
108
128
|
req.end();
|
|
109
129
|
}
|
|
110
|
-
doGet(url, maxRedirects);
|
|
130
|
+
doGet(url, maxRedirects, '');
|
|
111
131
|
});
|
|
112
132
|
}
|
|
113
133
|
|
|
@@ -166,7 +186,7 @@ async function readViaGraphApi(channel, projectRoot) {
|
|
|
166
186
|
const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
|
|
167
187
|
|
|
168
188
|
if (!response.values || response.values.length === 0) return [];
|
|
169
|
-
return parseRowsWithColumnMap(response.values, channel.column_map);
|
|
189
|
+
return parseRowsWithColumnMap(response.values, channel.column_map, channel.id);
|
|
170
190
|
}
|
|
171
191
|
|
|
172
192
|
// ─── SharePoint sharing link (anonymous WOPI) ─────────────────────────────────
|
|
@@ -177,12 +197,13 @@ function isSharingLink(url) {
|
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
/**
|
|
180
|
-
* Resolve a SharePoint anonymous sharing link to
|
|
200
|
+
* Resolve a SharePoint anonymous sharing link to {downloadUrl, cookies}.
|
|
181
201
|
* Technique: load WOPI viewer page → scrape FileGetUrl (temp auth token embedded in HTML).
|
|
202
|
+
* Returns the cookies collected during the redirect chain — they must be forwarded
|
|
203
|
+
* when downloading (SharePoint FedAuth cookie is required for download.aspx).
|
|
182
204
|
*/
|
|
183
205
|
async function resolveSharePointDownloadUrl(sharingUrl) {
|
|
184
|
-
|
|
185
|
-
const { status, body } = await httpsGetRaw(sharingUrl);
|
|
206
|
+
const { status, body, cookies } = await httpsGetRaw(sharingUrl);
|
|
186
207
|
if (status !== 200) {
|
|
187
208
|
throw new Error(`SharePoint sharing link returned HTTP ${status}. The link may be expired or restricted.`);
|
|
188
209
|
}
|
|
@@ -195,19 +216,35 @@ async function resolveSharePointDownloadUrl(sharingUrl) {
|
|
|
195
216
|
throw new Error('Could not extract download URL from SharePoint viewer page. The file may require sign-in.');
|
|
196
217
|
}
|
|
197
218
|
|
|
198
|
-
// Unescape JSON unicode escapes (
|
|
199
|
-
|
|
219
|
+
// Unescape JSON unicode escapes (& → &)
|
|
220
|
+
const downloadUrl = match[1].replace(/\\u([\da-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
221
|
+
return { downloadUrl, cookies };
|
|
200
222
|
}
|
|
201
223
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Download xlsx bytes.
|
|
226
|
+
* cookies: FedAuth cookies from WOPI page — required for download.aspx redirect chain.
|
|
227
|
+
*/
|
|
228
|
+
async function downloadXlsx(downloadUrl, cookies) {
|
|
229
|
+
const extraHeaders = { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*' };
|
|
230
|
+
const { status, body } = await httpsGetRaw(downloadUrl, extraHeaders, 8);
|
|
207
231
|
if (status !== 200) {
|
|
232
|
+
// Retry with cookies if initial attempt fails (some SharePoint tenants need FedAuth)
|
|
233
|
+
if (cookies) {
|
|
234
|
+
const retry = await httpsGetRaw(downloadUrl, { ...extraHeaders, Cookie: cookies }, 8);
|
|
235
|
+
if (retry.status === 200 && retry.body.length > 100) return retry.body;
|
|
236
|
+
}
|
|
208
237
|
throw new Error(`Failed to download xlsx: HTTP ${status}`);
|
|
209
238
|
}
|
|
210
|
-
|
|
239
|
+
// If we got HTML instead of binary (redirect page), retry with cookies
|
|
240
|
+
const magic = body.slice(0, 4).toString('hex');
|
|
241
|
+
if (magic !== '504b0304' && cookies) {
|
|
242
|
+
const retry = await httpsGetRaw(downloadUrl, { ...extraHeaders, Cookie: cookies }, 8);
|
|
243
|
+
if (retry.status === 200 && retry.body.slice(0, 4).toString('hex') === '504b0304') {
|
|
244
|
+
return retry.body;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return body;
|
|
211
248
|
}
|
|
212
249
|
|
|
213
250
|
// ─── xlsx parser (SheetJS) ────────────────────────────────────────────────────
|
|
@@ -249,23 +286,69 @@ function parseXlsxBuffer(buffer, sheetName) {
|
|
|
249
286
|
|
|
250
287
|
async function readViaSharingLink(channel) {
|
|
251
288
|
const sharingUrl = channel.sharing_url;
|
|
252
|
-
const downloadUrl = await resolveSharePointDownloadUrl(sharingUrl);
|
|
253
|
-
const xlsxBuffer = await downloadXlsx(downloadUrl);
|
|
289
|
+
const { downloadUrl, cookies } = await resolveSharePointDownloadUrl(sharingUrl);
|
|
290
|
+
const xlsxBuffer = await downloadXlsx(downloadUrl, cookies);
|
|
254
291
|
const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
|
|
255
292
|
|
|
256
293
|
if (!rows || rows.length === 0) return [];
|
|
257
|
-
return parseRowsWithColumnMap(rows, channel.column_map);
|
|
294
|
+
return parseRowsWithColumnMap(rows, channel.column_map, channel.id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── Vietnamese/English header → field name auto-detection ────────────────────
|
|
298
|
+
|
|
299
|
+
const HEADER_ALIASES = {
|
|
300
|
+
id: ['stt', 'id', 'no', '#', 'mã', 'ma'],
|
|
301
|
+
title: ['title', 'tiêu đề', 'tieu de', 'tên', 'ten', 'màn hình', 'man hinh', 'tên module', 'tên testcase', 'summary'],
|
|
302
|
+
description: ['mô tả lỗi', 'mo ta loi', 'description', 'mô tả', 'mo ta', 'chi tiết', 'chi tiet', 'detail', 'nội dung', 'noi dung'],
|
|
303
|
+
status: ['status', 'trạng thái', 'trang thai', 'kết quả', 'ket qua', 'result', 'p/f/n'],
|
|
304
|
+
reporter: ['reporter', 'người báo', 'nguoi bao', 'tester', 'người tạo', 'nguoi tao'],
|
|
305
|
+
priority: ['priority', 'mức độ', 'muc do', 'độ ưu tiên', 'severity'],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
function autoDetectColumnMap(headerRow) {
|
|
309
|
+
const map = {};
|
|
310
|
+
headerRow.forEach((cell, idx) => {
|
|
311
|
+
const normalized = String(cell).toLowerCase().trim();
|
|
312
|
+
if (!normalized) return;
|
|
313
|
+
for (const [field, aliases] of Object.entries(HEADER_ALIASES)) {
|
|
314
|
+
if (!map[field] && aliases.some(a => normalized.includes(a))) {
|
|
315
|
+
// Convert 0-based index to Excel letter (A=0, B=1, ...)
|
|
316
|
+
let col = '';
|
|
317
|
+
let n = idx;
|
|
318
|
+
do { col = String.fromCharCode(65 + (n % 26)) + col; n = Math.floor(n / 26) - 1; } while (n >= 0);
|
|
319
|
+
map[field] = col;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return map;
|
|
258
324
|
}
|
|
259
325
|
|
|
260
326
|
// ─── Common row → ticket mapper ───────────────────────────────────────────────
|
|
261
327
|
|
|
262
|
-
function parseRowsWithColumnMap(values, colMap) {
|
|
328
|
+
function parseRowsWithColumnMap(values, colMap, channelId) {
|
|
329
|
+
// Find first non-empty row to use as auto-detect source if needed
|
|
330
|
+
const firstNonEmpty = values.find(r => r.some(c => c !== ''));
|
|
331
|
+
|
|
332
|
+
// Auto-detect or fall back to raw if still missing
|
|
333
|
+
const resolvedMap = (colMap && Object.keys(colMap).length > 0)
|
|
334
|
+
? colMap
|
|
335
|
+
: (firstNonEmpty ? autoDetectColumnMap(firstNonEmpty) : {});
|
|
336
|
+
|
|
337
|
+
if (!colMap || Object.keys(colMap).length === 0) {
|
|
338
|
+
process.stderr.write(
|
|
339
|
+
`[vp-intake] No column_map configured. Auto-detected: ${JSON.stringify(resolvedMap)}. ` +
|
|
340
|
+
`Header row: ${JSON.stringify(firstNonEmpty || [])}\n`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
263
344
|
const tickets = [];
|
|
345
|
+
// Skip header row(s): if auto-detected, skip until first row that has a non-header value
|
|
346
|
+
const startRow = (colMap && Object.keys(colMap).length > 0) ? 1 : values.findIndex(r => r.some(c => c !== '')) + 1;
|
|
264
347
|
|
|
265
|
-
for (let i =
|
|
348
|
+
for (let i = startRow; i < values.length; i++) {
|
|
266
349
|
const row = values[i];
|
|
267
350
|
const get = (field) => {
|
|
268
|
-
const col =
|
|
351
|
+
const col = resolvedMap[field];
|
|
269
352
|
if (!col) return '';
|
|
270
353
|
const idx = colLetterToIndex(col);
|
|
271
354
|
return row[idx] !== undefined ? String(row[idx]) : '';
|
|
@@ -279,7 +362,7 @@ function parseRowsWithColumnMap(values, colMap) {
|
|
|
279
362
|
date: get('date'),
|
|
280
363
|
status: get('status'),
|
|
281
364
|
_source_row: i,
|
|
282
|
-
_channel_id:
|
|
365
|
+
_channel_id: channelId,
|
|
283
366
|
};
|
|
284
367
|
|
|
285
368
|
if (!ticket.title && !ticket.description) continue;
|
|
@@ -325,4 +408,6 @@ module.exports = {
|
|
|
325
408
|
isSharingLink,
|
|
326
409
|
resolveSharePointDownloadUrl,
|
|
327
410
|
parseXlsxBuffer,
|
|
411
|
+
autoDetectColumnMap,
|
|
412
|
+
HEADER_ALIASES,
|
|
328
413
|
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { classifyTicket } = require('./classifier.cjs');
|
|
7
|
+
const { createRequestFile: _createRequestFile, nextRequestNumber } = require('./triage-ux.cjs');
|
|
8
|
+
|
|
9
|
+
// Internal helper — wraps triage-ux createRequestFile which isn't exported,
|
|
10
|
+
// so we re-implement the minimal version needed for headless auto-accept.
|
|
11
|
+
function createRequestFile(ticket, type, channel, projectRoot) {
|
|
12
|
+
const n = nextRequestNumber(type, projectRoot);
|
|
13
|
+
const id = `${type}-${String(n).padStart(3, '0')}`;
|
|
14
|
+
const now = new Date().toISOString().split('T')[0];
|
|
15
|
+
const typeLabel = type === 'BUG' ? 'Bug' : 'Enhancement';
|
|
16
|
+
|
|
17
|
+
const content = `# ${typeLabel}: ${ticket.title}
|
|
18
|
+
|
|
19
|
+
## Meta
|
|
20
|
+
- **ID**: ${id}
|
|
21
|
+
- **Type**: ${typeLabel}
|
|
22
|
+
- **Status**: new
|
|
23
|
+
- **Priority**: medium
|
|
24
|
+
- **Created**: ${now}
|
|
25
|
+
- **Reporter**: ${ticket.reporter || 'Auto-intake'}
|
|
26
|
+
- **Source**: ${channel.name} — ticket #${ticket.id}
|
|
27
|
+
- **Assignee**: AI
|
|
28
|
+
|
|
29
|
+
## Summary
|
|
30
|
+
${ticket.description || ticket.title}
|
|
31
|
+
|
|
32
|
+
## Acceptance Criteria
|
|
33
|
+
- [ ] (derived from description — fill in before planning)
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const reqDir = path.join(projectRoot, '.viepilot', 'requests');
|
|
37
|
+
if (!fs.existsSync(reqDir)) fs.mkdirSync(reqDir, { recursive: true });
|
|
38
|
+
const filePath = path.join(reqDir, `${id}.md`);
|
|
39
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
40
|
+
return { id, filePath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const CONFIDENCE_THRESHOLD = 0.7;
|
|
46
|
+
|
|
47
|
+
// ─── Pending-review accumulation ─────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async function appendPendingReview(tickets, channel, pendingPath) {
|
|
50
|
+
const filePath = pendingPath || path.join('.viepilot', 'intake', 'pending-review.json');
|
|
51
|
+
const existing = fs.existsSync(filePath)
|
|
52
|
+
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
53
|
+
: { items: [] };
|
|
54
|
+
|
|
55
|
+
existing.items.push(
|
|
56
|
+
...tickets.map((t) => ({
|
|
57
|
+
...t,
|
|
58
|
+
channel: channel.name,
|
|
59
|
+
queued_at: new Date().toISOString(),
|
|
60
|
+
})),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const dir = path.dirname(filePath);
|
|
64
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Triage report ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async function writeTriageReport({ accepted, queued, channel, projectRoot }) {
|
|
71
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
72
|
+
const reportDir = path.join(projectRoot, '.viepilot', 'intake');
|
|
73
|
+
if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
|
|
74
|
+
const reportPath = path.join(reportDir, `TRIAGE-auto-${timestamp}.md`);
|
|
75
|
+
|
|
76
|
+
const lines = [
|
|
77
|
+
`# Auto-Intake Triage Report`,
|
|
78
|
+
``,
|
|
79
|
+
`**Channel**: ${channel.name}`,
|
|
80
|
+
`**Date**: ${new Date().toISOString().split('T')[0]}`,
|
|
81
|
+
`**Mode**: auto (scheduled)`,
|
|
82
|
+
``,
|
|
83
|
+
`## Auto-accepted (confidence ≥ ${CONFIDENCE_THRESHOLD})`,
|
|
84
|
+
``,
|
|
85
|
+
...accepted.map((t) => `- **${t.request_id}**: ${t.ticket.title} (confidence: ${(t.ticket._confidence || 0).toFixed(2)})`),
|
|
86
|
+
``,
|
|
87
|
+
`## Queued for review (confidence < ${CONFIDENCE_THRESHOLD})`,
|
|
88
|
+
``,
|
|
89
|
+
...queued.map((t) => `- #${t.id || t._source_row}: ${t.title} (confidence: ${(t._confidence || 0).toFixed(2)})`),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
fs.writeFileSync(reportPath, lines.join('\n'), 'utf8');
|
|
93
|
+
return reportPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Schedule management ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function readSchedule(projectRoot) {
|
|
99
|
+
const filePath = path.join(projectRoot || '.', '.viepilot', 'intake', 'schedule.json');
|
|
100
|
+
if (!fs.existsSync(filePath)) return null;
|
|
101
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function createSchedule(cronExpression, channelId, projectRoot) {
|
|
105
|
+
const root = projectRoot || '.';
|
|
106
|
+
// CronCreate is a deferred tool — the invoking agent must call
|
|
107
|
+
// ToolSearch("select:CronCreate") before using it.
|
|
108
|
+
// Non-CC fallback: print guidance.
|
|
109
|
+
const isClaudeCode = process.env.CLAUDE_CODE === '1' || process.env.VP_ADAPTER === 'claude-code';
|
|
110
|
+
|
|
111
|
+
if (!isClaudeCode) {
|
|
112
|
+
console.log('Scheduling requires Claude Code. Use cron + CLI instead.');
|
|
113
|
+
console.log(`Suggested: cron "${cronExpression} claude /vp-intake --channel ${channelId} --auto"`);
|
|
114
|
+
return { success: false, error: 'Not running in Claude Code context' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// In CC context, the caller (SKILL.md) must invoke CronCreate via ToolSearch.
|
|
118
|
+
// This function writes schedule.json after receiving the schedule_id from CronCreate.
|
|
119
|
+
const scheduleData = {
|
|
120
|
+
cron: cronExpression,
|
|
121
|
+
schedule_id: null, // filled by caller after CronCreate response
|
|
122
|
+
channel_id: channelId,
|
|
123
|
+
created: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const scheduleDir = path.join(root, '.viepilot', 'intake');
|
|
127
|
+
if (!fs.existsSync(scheduleDir)) fs.mkdirSync(scheduleDir, { recursive: true });
|
|
128
|
+
fs.writeFileSync(path.join(scheduleDir, 'schedule.json'), JSON.stringify(scheduleData, null, 2));
|
|
129
|
+
return { success: true, scheduleData };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function deleteSchedule(projectRoot) {
|
|
133
|
+
const root = projectRoot || '.';
|
|
134
|
+
const filePath = path.join(root, '.viepilot', 'intake', 'schedule.json');
|
|
135
|
+
if (!fs.existsSync(filePath)) {
|
|
136
|
+
return { success: false, error: 'No schedule found — nothing to delete' };
|
|
137
|
+
}
|
|
138
|
+
const { schedule_id } = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
139
|
+
// Caller must invoke CronDelete({ schedule_id }) via ToolSearch("select:CronDelete")
|
|
140
|
+
fs.unlinkSync(filePath);
|
|
141
|
+
return { success: true, schedule_id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Main auto-intake runner ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function runAutoIntake(tickets, channel, options = {}) {
|
|
147
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
148
|
+
|
|
149
|
+
// 1. Classify with confidence
|
|
150
|
+
const classified = tickets.map((t) => {
|
|
151
|
+
const { classified: cls, confidence } = classifyTicket(t);
|
|
152
|
+
return { ...t, _classified: cls, _confidence: confidence };
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// 2. Partition: accepted (≥ threshold) vs. queued (< threshold)
|
|
156
|
+
const accepted = classified.filter((t) => t._confidence >= CONFIDENCE_THRESHOLD && t._classified !== 'UNCLEAR');
|
|
157
|
+
const queued = classified.filter((t) => t._confidence < CONFIDENCE_THRESHOLD || t._classified === 'UNCLEAR');
|
|
158
|
+
|
|
159
|
+
// 3. Auto-create request files for accepted tickets
|
|
160
|
+
const acceptedWithIds = [];
|
|
161
|
+
for (const t of accepted) {
|
|
162
|
+
const { id } = createRequestFile(t, t._classified, channel, projectRoot);
|
|
163
|
+
acceptedWithIds.push({ ticket: t, request_id: id });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Append queued tickets to pending-review.json
|
|
167
|
+
if (queued.length > 0) {
|
|
168
|
+
await appendPendingReview(queued, channel);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 5. Write triage report
|
|
172
|
+
const reportPath = await writeTriageReport({ accepted: acceptedWithIds, queued, channel, projectRoot });
|
|
173
|
+
|
|
174
|
+
// 6. Print auto-run summary
|
|
175
|
+
console.log(`[AUTO INTAKE] Channel: ${channel.name} | ${tickets.length} tickets classified`);
|
|
176
|
+
if (acceptedWithIds.length > 0) {
|
|
177
|
+
console.log(`Auto-accepted (${acceptedWithIds.length}): ${acceptedWithIds.map((a) => a.request_id).join(', ')} (confidence ≥ ${CONFIDENCE_THRESHOLD})`);
|
|
178
|
+
}
|
|
179
|
+
if (queued.length > 0) {
|
|
180
|
+
console.log(`Queued for review (${queued.length}): ${queued.map((t) => `${t.id || t._source_row} (confidence ${(t._confidence || 0).toFixed(2)})`).join(', ')}`);
|
|
181
|
+
}
|
|
182
|
+
console.log(`Report: ${reportPath}`);
|
|
183
|
+
|
|
184
|
+
return { accepted: acceptedWithIds, queued, reportPath };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
runAutoIntake,
|
|
189
|
+
appendPendingReview,
|
|
190
|
+
createSchedule,
|
|
191
|
+
deleteSchedule,
|
|
192
|
+
readSchedule,
|
|
193
|
+
CONFIDENCE_THRESHOLD,
|
|
194
|
+
};
|
package/lib/intake/channels.cjs
CHANGED
|
@@ -93,8 +93,8 @@ function validateChannel(channel) {
|
|
|
93
93
|
if (channel.type === 'google_sheets' && !channel.spreadsheet_id) {
|
|
94
94
|
throw new Error(`Google Sheets channel "${channel.id}" must have a "spreadsheet_id" field`);
|
|
95
95
|
}
|
|
96
|
-
if (channel.type === 'excel_m365' && !channel.workbook_id) {
|
|
97
|
-
throw new Error(`Excel/M365 channel "${channel.id}" must have
|
|
96
|
+
if (channel.type === 'excel_m365' && !channel.workbook_id && !channel.sharing_url) {
|
|
97
|
+
throw new Error(`Excel/M365 channel "${channel.id}" must have "workbook_id" or "sharing_url"`);
|
|
98
98
|
}
|
|
99
99
|
return true;
|
|
100
100
|
}
|
|
@@ -136,4 +136,45 @@ function initIntakeDir(projectRoot) {
|
|
|
136
136
|
return { intakeDir, channelsPath, credentialsDir };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
/** Returns true if the channel is an unfilled scaffold stub. */
|
|
140
|
+
function isStubChannel(ch) {
|
|
141
|
+
if (!ch || !ch.id) return true;
|
|
142
|
+
if (ch.id.endsWith('-example')) return true;
|
|
143
|
+
return JSON.stringify(ch).includes('REPLACE_WITH_');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Returns true if channels array has at least one non-stub channel. */
|
|
147
|
+
function hasRealChannels(channels) {
|
|
148
|
+
return Array.isArray(channels) && channels.some((ch) => !isStubChannel(ch));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Append a new channel to channels.json.
|
|
153
|
+
* Strips stub channels; preserves existing non-stub channels.
|
|
154
|
+
*/
|
|
155
|
+
function appendChannel(projectRoot, channelObj) {
|
|
156
|
+
initIntakeDir(projectRoot);
|
|
157
|
+
const channelsPath = path.join(projectRoot, CHANNELS_REL);
|
|
158
|
+
let existing = [];
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(fs.readFileSync(channelsPath, 'utf8'));
|
|
161
|
+
existing = Array.isArray(parsed.channels) ? parsed.channels : [];
|
|
162
|
+
} catch {
|
|
163
|
+
existing = [];
|
|
164
|
+
}
|
|
165
|
+
const nonStubs = existing.filter((ch) => !isStubChannel(ch));
|
|
166
|
+
const updated = [...nonStubs, channelObj];
|
|
167
|
+
fs.writeFileSync(channelsPath, JSON.stringify({ channels: updated }, null, 2) + '\n', 'utf8');
|
|
168
|
+
return channelObj;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
loadChannels,
|
|
173
|
+
validateChannel,
|
|
174
|
+
getChannelById,
|
|
175
|
+
initIntakeDir,
|
|
176
|
+
CHANNELS_SCAFFOLD,
|
|
177
|
+
isStubChannel,
|
|
178
|
+
hasRealChannels,
|
|
179
|
+
appendChannel,
|
|
180
|
+
};
|
|
@@ -18,15 +18,33 @@ const ENH_KEYWORDS = [
|
|
|
18
18
|
];
|
|
19
19
|
|
|
20
20
|
function classifyTicket(ticket) {
|
|
21
|
+
const titleWords = (ticket.title || '').trim().split(/\s+/).filter(Boolean);
|
|
22
|
+
const totalWords = titleWords.length || 1;
|
|
21
23
|
const text = `${ticket.title || ''} ${ticket.description || ''}`.toLowerCase();
|
|
22
24
|
|
|
25
|
+
let matchedKeywords = 0;
|
|
26
|
+
let classified = 'UNCLEAR';
|
|
27
|
+
|
|
23
28
|
for (const kw of BUG_KEYWORDS) {
|
|
24
|
-
if (text.includes(kw))
|
|
29
|
+
if (text.includes(kw)) {
|
|
30
|
+
classified = 'BUG';
|
|
31
|
+
matchedKeywords += titleWords.filter((w) => w.toLowerCase().includes(kw)).length || 1;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
25
34
|
}
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
|
|
36
|
+
if (classified === 'UNCLEAR') {
|
|
37
|
+
for (const kw of ENH_KEYWORDS) {
|
|
38
|
+
if (text.includes(kw)) {
|
|
39
|
+
classified = 'ENH';
|
|
40
|
+
matchedKeywords += titleWords.filter((w) => w.toLowerCase().includes(kw)).length || 1;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
28
44
|
}
|
|
29
|
-
|
|
45
|
+
|
|
46
|
+
const confidence = classified === 'UNCLEAR' ? 0.0 : Math.min(matchedKeywords / totalWords, 1.0);
|
|
47
|
+
return { classified, confidence };
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
module.exports = { classifyTicket };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TTL_DAYS = 7;
|
|
7
|
+
const MANIFEST_DIR_REL = path.join('.viepilot', 'intake');
|
|
8
|
+
|
|
9
|
+
function slugify(str) {
|
|
10
|
+
return String(str)
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^\w\s-]/g, '')
|
|
13
|
+
.replace(/[\s_]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function manifestPath(channelId, projectRoot) {
|
|
18
|
+
const root = projectRoot || process.cwd();
|
|
19
|
+
return path.join(root, MANIFEST_DIR_REL, `${slugify(channelId)}-manifest.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveManifest(channelId, manifest, projectRoot) {
|
|
23
|
+
const root = projectRoot || process.cwd();
|
|
24
|
+
const dir = path.join(root, MANIFEST_DIR_REL);
|
|
25
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const data = {
|
|
28
|
+
...manifest,
|
|
29
|
+
channel_id: slugify(channelId),
|
|
30
|
+
saved_at: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
fs.writeFileSync(manifestPath(channelId, root), JSON.stringify(data, null, 2), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadManifest(channelId, projectRoot) {
|
|
36
|
+
const p = manifestPath(channelId, projectRoot);
|
|
37
|
+
if (!fs.existsSync(p)) return null;
|
|
38
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isManifestFresh(manifest, ttlDays) {
|
|
42
|
+
if (!manifest) return false;
|
|
43
|
+
const days = typeof ttlDays === 'number' ? ttlDays : DEFAULT_TTL_DAYS;
|
|
44
|
+
const ts = manifest.analyzed_at || manifest.saved_at;
|
|
45
|
+
if (!ts) return false;
|
|
46
|
+
const ageMs = Date.now() - new Date(ts).getTime();
|
|
47
|
+
return ageMs < days * 86400000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getColumnMap(manifest, sheetName) {
|
|
51
|
+
if (!manifest || !Array.isArray(manifest.sheets)) return null;
|
|
52
|
+
const sheet = sheetName
|
|
53
|
+
? manifest.sheets.find((s) => s.name === sheetName)
|
|
54
|
+
: manifest.sheets[0];
|
|
55
|
+
if (!sheet || !sheet.columns) return null;
|
|
56
|
+
const map = {};
|
|
57
|
+
for (const [col, def] of Object.entries(sheet.columns)) {
|
|
58
|
+
if (def && def.field) map[def.field] = col;
|
|
59
|
+
}
|
|
60
|
+
return Object.keys(map).length ? map : {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getWriteBackConfig(manifest, sheetName) {
|
|
64
|
+
if (!manifest || !Array.isArray(manifest.sheets)) return null;
|
|
65
|
+
const sheet = sheetName
|
|
66
|
+
? manifest.sheets.find((s) => s.name === sheetName)
|
|
67
|
+
: manifest.sheets[0];
|
|
68
|
+
if (!sheet || !sheet.write_back) return null;
|
|
69
|
+
return sheet.write_back;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
saveManifest,
|
|
74
|
+
loadManifest,
|
|
75
|
+
isManifestFresh,
|
|
76
|
+
getColumnMap,
|
|
77
|
+
getWriteBackConfig,
|
|
78
|
+
manifestPath,
|
|
79
|
+
slugify,
|
|
80
|
+
DEFAULT_TTL_DAYS,
|
|
81
|
+
};
|