viepilot 2.50.1 → 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 +204 -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 +54 -6
- package/lib/intake/auto-intake.cjs +194 -0
- package/lib/intake/classifier.cjs +22 -4
- package/lib/intake/manifest.cjs +81 -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 +306 -7
- 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,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { classifyTicket } = require('./classifier.cjs');
|
|
7
|
+
const { createRequestFile: _createRequestFile, nextRequestNumber } = require('./triage-ux.cjs');
|
|
8
|
+
|
|
9
|
+
// Internal helper — wraps triage-ux createRequestFile which isn't exported,
|
|
10
|
+
// so we re-implement the minimal version needed for headless auto-accept.
|
|
11
|
+
function createRequestFile(ticket, type, channel, projectRoot) {
|
|
12
|
+
const n = nextRequestNumber(type, projectRoot);
|
|
13
|
+
const id = `${type}-${String(n).padStart(3, '0')}`;
|
|
14
|
+
const now = new Date().toISOString().split('T')[0];
|
|
15
|
+
const typeLabel = type === 'BUG' ? 'Bug' : 'Enhancement';
|
|
16
|
+
|
|
17
|
+
const content = `# ${typeLabel}: ${ticket.title}
|
|
18
|
+
|
|
19
|
+
## Meta
|
|
20
|
+
- **ID**: ${id}
|
|
21
|
+
- **Type**: ${typeLabel}
|
|
22
|
+
- **Status**: new
|
|
23
|
+
- **Priority**: medium
|
|
24
|
+
- **Created**: ${now}
|
|
25
|
+
- **Reporter**: ${ticket.reporter || 'Auto-intake'}
|
|
26
|
+
- **Source**: ${channel.name} — ticket #${ticket.id}
|
|
27
|
+
- **Assignee**: AI
|
|
28
|
+
|
|
29
|
+
## Summary
|
|
30
|
+
${ticket.description || ticket.title}
|
|
31
|
+
|
|
32
|
+
## Acceptance Criteria
|
|
33
|
+
- [ ] (derived from description — fill in before planning)
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const reqDir = path.join(projectRoot, '.viepilot', 'requests');
|
|
37
|
+
if (!fs.existsSync(reqDir)) fs.mkdirSync(reqDir, { recursive: true });
|
|
38
|
+
const filePath = path.join(reqDir, `${id}.md`);
|
|
39
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
40
|
+
return { id, filePath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const CONFIDENCE_THRESHOLD = 0.7;
|
|
46
|
+
|
|
47
|
+
// ─── Pending-review accumulation ─────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async function appendPendingReview(tickets, channel, pendingPath) {
|
|
50
|
+
const filePath = pendingPath || path.join('.viepilot', 'intake', 'pending-review.json');
|
|
51
|
+
const existing = fs.existsSync(filePath)
|
|
52
|
+
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
53
|
+
: { items: [] };
|
|
54
|
+
|
|
55
|
+
existing.items.push(
|
|
56
|
+
...tickets.map((t) => ({
|
|
57
|
+
...t,
|
|
58
|
+
channel: channel.name,
|
|
59
|
+
queued_at: new Date().toISOString(),
|
|
60
|
+
})),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const dir = path.dirname(filePath);
|
|
64
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Triage report ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async function writeTriageReport({ accepted, queued, channel, projectRoot }) {
|
|
71
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
72
|
+
const reportDir = path.join(projectRoot, '.viepilot', 'intake');
|
|
73
|
+
if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
|
|
74
|
+
const reportPath = path.join(reportDir, `TRIAGE-auto-${timestamp}.md`);
|
|
75
|
+
|
|
76
|
+
const lines = [
|
|
77
|
+
`# Auto-Intake Triage Report`,
|
|
78
|
+
``,
|
|
79
|
+
`**Channel**: ${channel.name}`,
|
|
80
|
+
`**Date**: ${new Date().toISOString().split('T')[0]}`,
|
|
81
|
+
`**Mode**: auto (scheduled)`,
|
|
82
|
+
``,
|
|
83
|
+
`## Auto-accepted (confidence ≥ ${CONFIDENCE_THRESHOLD})`,
|
|
84
|
+
``,
|
|
85
|
+
...accepted.map((t) => `- **${t.request_id}**: ${t.ticket.title} (confidence: ${(t.ticket._confidence || 0).toFixed(2)})`),
|
|
86
|
+
``,
|
|
87
|
+
`## Queued for review (confidence < ${CONFIDENCE_THRESHOLD})`,
|
|
88
|
+
``,
|
|
89
|
+
...queued.map((t) => `- #${t.id || t._source_row}: ${t.title} (confidence: ${(t._confidence || 0).toFixed(2)})`),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
fs.writeFileSync(reportPath, lines.join('\n'), 'utf8');
|
|
93
|
+
return reportPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Schedule management ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function readSchedule(projectRoot) {
|
|
99
|
+
const filePath = path.join(projectRoot || '.', '.viepilot', 'intake', 'schedule.json');
|
|
100
|
+
if (!fs.existsSync(filePath)) return null;
|
|
101
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function createSchedule(cronExpression, channelId, projectRoot) {
|
|
105
|
+
const root = projectRoot || '.';
|
|
106
|
+
// CronCreate is a deferred tool — the invoking agent must call
|
|
107
|
+
// ToolSearch("select:CronCreate") before using it.
|
|
108
|
+
// Non-CC fallback: print guidance.
|
|
109
|
+
const isClaudeCode = process.env.CLAUDE_CODE === '1' || process.env.VP_ADAPTER === 'claude-code';
|
|
110
|
+
|
|
111
|
+
if (!isClaudeCode) {
|
|
112
|
+
console.log('Scheduling requires Claude Code. Use cron + CLI instead.');
|
|
113
|
+
console.log(`Suggested: cron "${cronExpression} claude /vp-intake --channel ${channelId} --auto"`);
|
|
114
|
+
return { success: false, error: 'Not running in Claude Code context' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// In CC context, the caller (SKILL.md) must invoke CronCreate via ToolSearch.
|
|
118
|
+
// This function writes schedule.json after receiving the schedule_id from CronCreate.
|
|
119
|
+
const scheduleData = {
|
|
120
|
+
cron: cronExpression,
|
|
121
|
+
schedule_id: null, // filled by caller after CronCreate response
|
|
122
|
+
channel_id: channelId,
|
|
123
|
+
created: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const scheduleDir = path.join(root, '.viepilot', 'intake');
|
|
127
|
+
if (!fs.existsSync(scheduleDir)) fs.mkdirSync(scheduleDir, { recursive: true });
|
|
128
|
+
fs.writeFileSync(path.join(scheduleDir, 'schedule.json'), JSON.stringify(scheduleData, null, 2));
|
|
129
|
+
return { success: true, scheduleData };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function deleteSchedule(projectRoot) {
|
|
133
|
+
const root = projectRoot || '.';
|
|
134
|
+
const filePath = path.join(root, '.viepilot', 'intake', 'schedule.json');
|
|
135
|
+
if (!fs.existsSync(filePath)) {
|
|
136
|
+
return { success: false, error: 'No schedule found — nothing to delete' };
|
|
137
|
+
}
|
|
138
|
+
const { schedule_id } = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
139
|
+
// Caller must invoke CronDelete({ schedule_id }) via ToolSearch("select:CronDelete")
|
|
140
|
+
fs.unlinkSync(filePath);
|
|
141
|
+
return { success: true, schedule_id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Main auto-intake runner ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function runAutoIntake(tickets, channel, options = {}) {
|
|
147
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
148
|
+
|
|
149
|
+
// 1. Classify with confidence
|
|
150
|
+
const classified = tickets.map((t) => {
|
|
151
|
+
const { classified: cls, confidence } = classifyTicket(t);
|
|
152
|
+
return { ...t, _classified: cls, _confidence: confidence };
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// 2. Partition: accepted (≥ threshold) vs. queued (< threshold)
|
|
156
|
+
const accepted = classified.filter((t) => t._confidence >= CONFIDENCE_THRESHOLD && t._classified !== 'UNCLEAR');
|
|
157
|
+
const queued = classified.filter((t) => t._confidence < CONFIDENCE_THRESHOLD || t._classified === 'UNCLEAR');
|
|
158
|
+
|
|
159
|
+
// 3. Auto-create request files for accepted tickets
|
|
160
|
+
const acceptedWithIds = [];
|
|
161
|
+
for (const t of accepted) {
|
|
162
|
+
const { id } = createRequestFile(t, t._classified, channel, projectRoot);
|
|
163
|
+
acceptedWithIds.push({ ticket: t, request_id: id });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Append queued tickets to pending-review.json
|
|
167
|
+
if (queued.length > 0) {
|
|
168
|
+
await appendPendingReview(queued, channel);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 5. Write triage report
|
|
172
|
+
const reportPath = await writeTriageReport({ accepted: acceptedWithIds, queued, channel, projectRoot });
|
|
173
|
+
|
|
174
|
+
// 6. Print auto-run summary
|
|
175
|
+
console.log(`[AUTO INTAKE] Channel: ${channel.name} | ${tickets.length} tickets classified`);
|
|
176
|
+
if (acceptedWithIds.length > 0) {
|
|
177
|
+
console.log(`Auto-accepted (${acceptedWithIds.length}): ${acceptedWithIds.map((a) => a.request_id).join(', ')} (confidence ≥ ${CONFIDENCE_THRESHOLD})`);
|
|
178
|
+
}
|
|
179
|
+
if (queued.length > 0) {
|
|
180
|
+
console.log(`Queued for review (${queued.length}): ${queued.map((t) => `${t.id || t._source_row} (confidence ${(t._confidence || 0).toFixed(2)})`).join(', ')}`);
|
|
181
|
+
}
|
|
182
|
+
console.log(`Report: ${reportPath}`);
|
|
183
|
+
|
|
184
|
+
return { accepted: acceptedWithIds, queued, reportPath };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
runAutoIntake,
|
|
189
|
+
appendPendingReview,
|
|
190
|
+
createSchedule,
|
|
191
|
+
deleteSchedule,
|
|
192
|
+
readSchedule,
|
|
193
|
+
CONFIDENCE_THRESHOLD,
|
|
194
|
+
};
|
|
@@ -18,15 +18,33 @@ const ENH_KEYWORDS = [
|
|
|
18
18
|
];
|
|
19
19
|
|
|
20
20
|
function classifyTicket(ticket) {
|
|
21
|
+
const titleWords = (ticket.title || '').trim().split(/\s+/).filter(Boolean);
|
|
22
|
+
const totalWords = titleWords.length || 1;
|
|
21
23
|
const text = `${ticket.title || ''} ${ticket.description || ''}`.toLowerCase();
|
|
22
24
|
|
|
25
|
+
let matchedKeywords = 0;
|
|
26
|
+
let classified = 'UNCLEAR';
|
|
27
|
+
|
|
23
28
|
for (const kw of BUG_KEYWORDS) {
|
|
24
|
-
if (text.includes(kw))
|
|
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
|
+
};
|
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 };
|