task-summary-extractor 8.3.0 → 9.0.1
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/.env.example +38 -0
- package/ARCHITECTURE.md +99 -3
- package/EXPLORATION.md +148 -89
- package/QUICK_START.md +5 -2
- package/README.md +51 -7
- package/bin/taskex.js +11 -4
- package/package.json +38 -5
- package/prompt.json +2 -2
- package/src/config.js +52 -3
- package/src/logger.js +7 -4
- package/src/modes/focused-reanalysis.js +2 -1
- package/src/modes/progress-updater.js +1 -1
- package/src/phases/_shared.js +43 -0
- package/src/phases/compile.js +101 -0
- package/src/phases/deep-dive.js +118 -0
- package/src/phases/discover.js +178 -0
- package/src/phases/init.js +199 -0
- package/src/phases/output.js +238 -0
- package/src/phases/process-media.js +633 -0
- package/src/phases/services.js +104 -0
- package/src/phases/summary.js +86 -0
- package/src/pipeline.js +432 -1464
- package/src/renderers/docx.js +531 -0
- package/src/renderers/html.js +672 -0
- package/src/renderers/markdown.js +15 -183
- package/src/renderers/pdf.js +90 -0
- package/src/renderers/shared.js +215 -0
- package/src/schemas/analysis-compiled.schema.json +381 -0
- package/src/schemas/analysis-segment.schema.json +380 -0
- package/src/services/doc-parser.js +346 -0
- package/src/services/gemini.js +118 -45
- package/src/services/video.js +123 -8
- package/src/utils/adaptive-budget.js +6 -4
- package/src/utils/checkpoint.js +2 -1
- package/src/utils/cli.js +132 -111
- package/src/utils/colors.js +83 -0
- package/src/utils/confidence-filter.js +139 -0
- package/src/utils/diff-engine.js +2 -1
- package/src/utils/global-config.js +6 -5
- package/src/utils/health-dashboard.js +11 -9
- package/src/utils/json-parser.js +4 -2
- package/src/utils/learning-loop.js +3 -2
- package/src/utils/progress-bar.js +286 -0
- package/src/utils/quality-gate.js +10 -8
- package/src/utils/retry.js +3 -1
- package/src/utils/schema-validator.js +314 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML renderer — generates a self-contained HTML report from compiled results.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the same data flow and sections as markdown.js, but outputs
|
|
5
|
+
* a fully styled, interactive HTML document with:
|
|
6
|
+
* - Inline CSS (no external dependencies)
|
|
7
|
+
* - Collapsible sections
|
|
8
|
+
* - Sortable tables
|
|
9
|
+
* - Dark/light theme toggle
|
|
10
|
+
* - Print-friendly layout
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
stripParens, normalizeKey, clusterNames, resolve,
|
|
17
|
+
dedupBy, normalizeDesc, dedupByDesc,
|
|
18
|
+
fmtTs, priBadge, confBadge, confBadgeFull,
|
|
19
|
+
escHtml,
|
|
20
|
+
} = require('./shared');
|
|
21
|
+
|
|
22
|
+
// ════════════════════════════════════════════════════════════
|
|
23
|
+
// Inline CSS
|
|
24
|
+
// ════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
const CSS = `
|
|
27
|
+
:root {
|
|
28
|
+
--bg: #ffffff; --fg: #1a1a2e; --card: #f8f9fa; --border: #dee2e6;
|
|
29
|
+
--accent: #4361ee; --accent-light: #e8edff; --success: #2ecc71;
|
|
30
|
+
--warning: #f39c12; --danger: #e74c3c; --muted: #6c757d;
|
|
31
|
+
--table-stripe: #f2f4f8; --shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
32
|
+
}
|
|
33
|
+
[data-theme="dark"] {
|
|
34
|
+
--bg: #1a1a2e; --fg: #e0e0e0; --card: #16213e; --border: #2a2a4a;
|
|
35
|
+
--accent: #7b8cff; --accent-light: #1e2a4a; --success: #27ae60;
|
|
36
|
+
--warning: #e67e22; --danger: #c0392b; --muted: #aaa;
|
|
37
|
+
--table-stripe: #1e2a3e; --shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
38
|
+
}
|
|
39
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
40
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
41
|
+
background: var(--bg); color: var(--fg); line-height: 1.6; padding: 2rem; max-width: 1100px; margin: 0 auto; }
|
|
42
|
+
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; border-bottom: 3px solid var(--accent); padding-bottom: 0.5rem; }
|
|
43
|
+
h2 { font-size: 1.4rem; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--accent); }
|
|
44
|
+
h3 { font-size: 1.15rem; margin-top: 1.2rem; margin-bottom: 0.4rem; }
|
|
45
|
+
h4 { font-size: 1rem; margin-top: 0.8rem; margin-bottom: 0.3rem; color: var(--muted); }
|
|
46
|
+
hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
|
47
|
+
a { color: var(--accent); text-decoration: none; }
|
|
48
|
+
a:hover { text-decoration: underline; }
|
|
49
|
+
blockquote { border-left: 4px solid var(--accent); padding: 0.5rem 1rem; margin: 0.5rem 0;
|
|
50
|
+
background: var(--accent-light); border-radius: 0 4px 4px 0; }
|
|
51
|
+
code { background: var(--card); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
|
52
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
|
53
|
+
padding: 1rem 1.2rem; margin: 0.8rem 0; box-shadow: var(--shadow); }
|
|
54
|
+
.badge { display: inline-block; padding: 0.1em 0.5em; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
|
|
55
|
+
.badge-high { background: #d4edda; color: #155724; }
|
|
56
|
+
.badge-medium { background: #fff3cd; color: #856404; }
|
|
57
|
+
.badge-low { background: #f8d7da; color: #721c24; }
|
|
58
|
+
.badge-pri-high { background: var(--danger); color: #fff; }
|
|
59
|
+
.badge-pri-medium { background: var(--warning); color: #fff; }
|
|
60
|
+
.badge-pri-low { background: var(--success); color: #fff; }
|
|
61
|
+
.badge-pri-critical { background: #5c0011; color: #fff; }
|
|
62
|
+
table { width: 100%; border-collapse: collapse; margin: 0.5rem 0; font-size: 0.9rem; }
|
|
63
|
+
th { background: var(--accent); color: #fff; text-align: left; padding: 0.5rem 0.7rem; }
|
|
64
|
+
td { padding: 0.4rem 0.7rem; border-bottom: 1px solid var(--border); }
|
|
65
|
+
tr:nth-child(even) td { background: var(--table-stripe); }
|
|
66
|
+
details { margin: 0.5rem 0; }
|
|
67
|
+
details summary { cursor: pointer; font-weight: 600; padding: 0.3rem 0; color: var(--accent); }
|
|
68
|
+
details summary:hover { text-decoration: underline; }
|
|
69
|
+
ul, ol { padding-left: 1.5rem; margin: 0.3rem 0; }
|
|
70
|
+
li { margin: 0.2rem 0; }
|
|
71
|
+
.checkbox { margin-right: 0.3rem; }
|
|
72
|
+
.meta-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; margin: 0.5rem 0; font-size: 0.9rem; }
|
|
73
|
+
.meta-grid dt { font-weight: 600; color: var(--muted); }
|
|
74
|
+
.conf-bar { display: flex; gap: 0; height: 8px; border-radius: 4px; overflow: hidden; margin: 0.3rem 0; }
|
|
75
|
+
.conf-bar span { display: block; }
|
|
76
|
+
.conf-bar .high { background: var(--success); }
|
|
77
|
+
.conf-bar .med { background: var(--warning); }
|
|
78
|
+
.conf-bar .low { background: var(--danger); }
|
|
79
|
+
.theme-toggle { position: fixed; top: 1rem; right: 1rem; background: var(--card); border: 1px solid var(--border);
|
|
80
|
+
border-radius: 50%; width: 36px; height: 36px; cursor: pointer; font-size: 1.1rem; z-index: 100; }
|
|
81
|
+
.person-section { border-left: 3px solid var(--accent); padding-left: 1rem; margin: 1rem 0; }
|
|
82
|
+
.star { border-left-color: var(--warning); }
|
|
83
|
+
.footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border);
|
|
84
|
+
font-size: 0.85rem; color: var(--muted); text-align: center; }
|
|
85
|
+
@media print { .theme-toggle { display: none; } body { max-width: 100%; padding: 0.5cm; } }
|
|
86
|
+
@media (max-width: 700px) { body { padding: 0.8rem; } table { font-size: 0.8rem; } }
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
const JS_SCRIPT = `
|
|
90
|
+
document.querySelector('.theme-toggle').addEventListener('click', () => {
|
|
91
|
+
const d = document.documentElement;
|
|
92
|
+
d.dataset.theme = d.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
93
|
+
localStorage.setItem('theme', d.dataset.theme);
|
|
94
|
+
});
|
|
95
|
+
(function() {
|
|
96
|
+
const saved = localStorage.getItem('theme');
|
|
97
|
+
if (saved) document.documentElement.dataset.theme = saved;
|
|
98
|
+
else if (matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.dataset.theme = 'dark';
|
|
99
|
+
})();
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
// ════════════════════════════════════════════════════════════
|
|
103
|
+
// Helper builders
|
|
104
|
+
// ════════════════════════════════════════════════════════════
|
|
105
|
+
|
|
106
|
+
const e = escHtml;
|
|
107
|
+
|
|
108
|
+
function confBadgeHtml(c) {
|
|
109
|
+
if (!c) return '';
|
|
110
|
+
const cls = { HIGH: 'badge-high', MEDIUM: 'badge-medium', LOW: 'badge-low' }[c] || '';
|
|
111
|
+
return ` <span class="badge ${cls}">${e(c)}</span>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function priBadgeHtml(p) {
|
|
115
|
+
if (!p) return '';
|
|
116
|
+
const cls = { high: 'badge-pri-high', medium: 'badge-pri-medium', low: 'badge-pri-low', critical: 'badge-pri-critical' }[p] || '';
|
|
117
|
+
return ` <span class="badge ${cls}">${e(p)}</span>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function tsHtml(ts, seg) {
|
|
121
|
+
if (!ts) return '';
|
|
122
|
+
const s = seg ? ` <small>(Seg ${seg})</small>` : '';
|
|
123
|
+
return `<code>${e(ts)}</code>${s}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ════════════════════════════════════════════════════════════
|
|
127
|
+
// Main Renderer
|
|
128
|
+
// ════════════════════════════════════════════════════════════
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Render compiled analysis results as a self-contained HTML document.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} options
|
|
134
|
+
* @param {object} options.compiled - The AI-compiled unified analysis
|
|
135
|
+
* @param {object} options.meta - Call metadata
|
|
136
|
+
* @returns {string} Complete HTML document
|
|
137
|
+
*/
|
|
138
|
+
function renderResultsHtml({ compiled, meta }) {
|
|
139
|
+
const h = [];
|
|
140
|
+
const ln = (...args) => h.push(args.join(''));
|
|
141
|
+
|
|
142
|
+
if (!compiled) {
|
|
143
|
+
return `<!DOCTYPE html><html><head><title>Call Analysis</title></head><body>
|
|
144
|
+
<h1>Call Analysis</h1><p>No compiled result available.</p></body></html>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Extract & deduplicate all data (same as markdown.js) ──
|
|
148
|
+
const allTickets = dedupBy(compiled.tickets || [], t => t.ticket_id);
|
|
149
|
+
const allCRs = dedupBy(compiled.change_requests || [], cr => cr.id);
|
|
150
|
+
const allActions = dedupBy(compiled.action_items || [], ai => ai.id);
|
|
151
|
+
const allBlockers = dedupBy(compiled.blockers || [], b => b.id);
|
|
152
|
+
const allScope = dedupBy(compiled.scope_changes || [], sc => sc.id);
|
|
153
|
+
const allFiles = dedupBy(compiled.file_references || [], f => f.resolved_path || f.file_name);
|
|
154
|
+
const summary = compiled.summary || compiled.executive_summary || '';
|
|
155
|
+
const yourTasks = compiled.your_tasks || null;
|
|
156
|
+
|
|
157
|
+
// ── Discover & cluster participant names ──
|
|
158
|
+
const rawNames = new Set();
|
|
159
|
+
const addName = n => { if (n && n.trim()) rawNames.add(n.trim()); };
|
|
160
|
+
allActions.forEach(ai => addName(ai.assigned_to));
|
|
161
|
+
allCRs.forEach(cr => addName(cr.assigned_to));
|
|
162
|
+
allBlockers.forEach(b => addName(b.owner));
|
|
163
|
+
allScope.forEach(sc => addName(sc.decided_by));
|
|
164
|
+
allTickets.forEach(t => { addName(t.assignee); addName(t.reviewer); });
|
|
165
|
+
if (yourTasks?.user_name) addName(yourTasks.user_name);
|
|
166
|
+
if (yourTasks) (yourTasks.tasks_waiting_on_others || []).forEach(w => addName(w.waiting_on));
|
|
167
|
+
|
|
168
|
+
const clusterMap = clusterNames([...rawNames]);
|
|
169
|
+
const teamKeywords = ['team', 'qa', 'dba', 'devops', 'db team', 'external'];
|
|
170
|
+
const people = [...clusterMap.keys()]
|
|
171
|
+
.filter(n => n && !teamKeywords.some(kw => n.toLowerCase() === kw))
|
|
172
|
+
.sort();
|
|
173
|
+
|
|
174
|
+
const nameMatch = (raw, canonical) => {
|
|
175
|
+
if (!raw || !canonical) return false;
|
|
176
|
+
return resolve(raw, clusterMap) === canonical;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const currentUserCanonical = meta.userName ? resolve(meta.userName, clusterMap) : null;
|
|
180
|
+
const orderedPeople = [];
|
|
181
|
+
if (currentUserCanonical && people.includes(currentUserCanonical)) orderedPeople.push(currentUserCanonical);
|
|
182
|
+
for (const p of people) if (p !== currentUserCanonical) orderedPeople.push(p);
|
|
183
|
+
|
|
184
|
+
// ══════════════════════════════════════════════════════
|
|
185
|
+
// HTML HEAD
|
|
186
|
+
// ══════════════════════════════════════════════════════
|
|
187
|
+
ln('<!DOCTYPE html>');
|
|
188
|
+
ln('<html lang="en">');
|
|
189
|
+
ln('<head>');
|
|
190
|
+
ln('<meta charset="utf-8">');
|
|
191
|
+
ln('<meta name="viewport" content="width=device-width, initial-scale=1">');
|
|
192
|
+
ln(`<title>Call Analysis — ${e(meta.callName || 'Report')}</title>`);
|
|
193
|
+
ln(`<style>${CSS}</style>`);
|
|
194
|
+
ln('</head>');
|
|
195
|
+
ln('<body>');
|
|
196
|
+
ln('<button class="theme-toggle" title="Toggle theme">🌗</button>');
|
|
197
|
+
|
|
198
|
+
// ══════════════════════════════════════════════════════
|
|
199
|
+
// HEADER
|
|
200
|
+
// ══════════════════════════════════════════════════════
|
|
201
|
+
ln(`<h1>📋 Call Analysis — ${e(meta.callName || 'Unknown')}</h1>`);
|
|
202
|
+
ln('<dl class="meta-grid">');
|
|
203
|
+
ln(`<dt>Date</dt><dd>${e(meta.processedAt ? meta.processedAt.slice(0, 10) : 'N/A')}</dd>`);
|
|
204
|
+
ln(`<dt>Participants</dt><dd>${e(orderedPeople.join(', ') || 'Unknown')}</dd>`);
|
|
205
|
+
ln(`<dt>Segments</dt><dd>${meta.segmentCount || 'N/A'}</dd>`);
|
|
206
|
+
ln(`<dt>Model</dt><dd>${e(meta.geminiModel || 'N/A')}</dd>`);
|
|
207
|
+
|
|
208
|
+
const comp = meta.compilation;
|
|
209
|
+
if (comp) {
|
|
210
|
+
const tu = comp.tokenUsage || {};
|
|
211
|
+
const durSec = comp.durationMs ? (comp.durationMs / 1000).toFixed(1) : '?';
|
|
212
|
+
ln(`<dt>Compilation</dt><dd>${durSec}s | ${(tu.inputTokens || 0).toLocaleString()} in → ${(tu.outputTokens || 0).toLocaleString()} out tokens</dd>`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cost = meta.costSummary;
|
|
216
|
+
if (cost && cost.totalTokens > 0) {
|
|
217
|
+
ln(`<dt>Cost</dt><dd>$${cost.totalCost.toFixed(4)} (${cost.totalTokens.toLocaleString()} tokens)</dd>`);
|
|
218
|
+
}
|
|
219
|
+
ln('</dl>');
|
|
220
|
+
|
|
221
|
+
// Confidence filter notice
|
|
222
|
+
if (compiled._filterMeta && compiled._filterMeta.minConfidence !== 'LOW') {
|
|
223
|
+
const fm = compiled._filterMeta;
|
|
224
|
+
const levelLabel = fm.minConfidence === 'HIGH' ? 'HIGH' : 'MEDIUM and HIGH';
|
|
225
|
+
ln('<div class="notice" style="background:#fff3cd;border:1px solid #ffc107;border-radius:6px;padding:10px 14px;margin:12px 0;color:#664d03;">');
|
|
226
|
+
ln(`⚠️ <strong>Confidence filter active:</strong> showing only ${e(levelLabel)} confidence items. `);
|
|
227
|
+
ln(`Kept ${fm.filteredCounts.total}/${fm.originalCounts.total} items (${fm.removed} removed). Full unfiltered data in <code>results.json</code>.`);
|
|
228
|
+
ln('</div>');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Segment details (collapsible)
|
|
232
|
+
const segs = meta.segments || [];
|
|
233
|
+
if (segs.length > 0) {
|
|
234
|
+
ln('<details>');
|
|
235
|
+
ln(`<summary>📼 Segment Details (${segs.length})</summary>`);
|
|
236
|
+
ln('<table><tr><th>#</th><th>File</th><th>Duration</th><th>Size</th></tr>');
|
|
237
|
+
segs.forEach((s, i) => {
|
|
238
|
+
ln(`<tr><td>${i + 1}</td><td>${e(s.file)}</td><td>${e(s.duration || '?')}</td><td>${e(s.sizeMB || '?')} MB</td></tr>`);
|
|
239
|
+
});
|
|
240
|
+
ln('</table></details>');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
ln('<hr>');
|
|
244
|
+
|
|
245
|
+
// ══════════════════════════════════════════════════════
|
|
246
|
+
// CONFIDENCE DISTRIBUTION
|
|
247
|
+
// ══════════════════════════════════════════════════════
|
|
248
|
+
const allConfItems = [...allTickets, ...allCRs, ...allActions, ...allBlockers, ...allScope];
|
|
249
|
+
if (allConfItems.length > 0) {
|
|
250
|
+
const confHigh = allConfItems.filter(i => i.confidence === 'HIGH').length;
|
|
251
|
+
const confMed = allConfItems.filter(i => i.confidence === 'MEDIUM').length;
|
|
252
|
+
const confLow = allConfItems.filter(i => i.confidence === 'LOW').length;
|
|
253
|
+
const confTotal = allConfItems.length;
|
|
254
|
+
|
|
255
|
+
if (confHigh + confMed + confLow > 0) {
|
|
256
|
+
ln('<h3>📊 Confidence Distribution</h3>');
|
|
257
|
+
ln('<div class="conf-bar">');
|
|
258
|
+
if (confHigh > 0) ln(`<span class="high" style="width:${((confHigh / confTotal) * 100).toFixed(1)}%" title="HIGH: ${confHigh}"></span>`);
|
|
259
|
+
if (confMed > 0) ln(`<span class="med" style="width:${((confMed / confTotal) * 100).toFixed(1)}%" title="MEDIUM: ${confMed}"></span>`);
|
|
260
|
+
if (confLow > 0) ln(`<span class="low" style="width:${((confLow / confTotal) * 100).toFixed(1)}%" title="LOW: ${confLow}"></span>`);
|
|
261
|
+
ln('</div>');
|
|
262
|
+
ln('<table><tr><th>Level</th><th>Count</th><th>%</th></tr>');
|
|
263
|
+
if (confHigh > 0) ln(`<tr><td>🟢 HIGH</td><td>${confHigh}</td><td>${((confHigh / confTotal) * 100).toFixed(0)}%</td></tr>`);
|
|
264
|
+
if (confMed > 0) ln(`<tr><td>🟡 MEDIUM</td><td>${confMed}</td><td>${((confMed / confTotal) * 100).toFixed(0)}%</td></tr>`);
|
|
265
|
+
if (confLow > 0) ln(`<tr><td>🔴 LOW</td><td>${confLow}</td><td>${((confLow / confTotal) * 100).toFixed(0)}%</td></tr>`);
|
|
266
|
+
ln('</table>');
|
|
267
|
+
if (confLow > 0) {
|
|
268
|
+
ln('<blockquote>⚠️ <strong>LOW confidence items</strong> need human verification before acting on them.</blockquote>');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ══════════════════════════════════════════════════════
|
|
274
|
+
// EXECUTIVE SUMMARY
|
|
275
|
+
// ══════════════════════════════════════════════════════
|
|
276
|
+
ln('<h2>📝 Executive Summary</h2>');
|
|
277
|
+
if (summary) ln(`<p>${e(summary)}</p>`);
|
|
278
|
+
|
|
279
|
+
// ── Completed in call ──
|
|
280
|
+
const completedInCall = yourTasks?.completed_in_call || [];
|
|
281
|
+
if (completedInCall.length > 0) {
|
|
282
|
+
ln('<h3>✅ Resolved During This Call</h3><ul>');
|
|
283
|
+
for (const item of completedInCall) ln(`<li>✅ ${e(item)}</li>`);
|
|
284
|
+
ln('</ul>');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
ln('<hr>');
|
|
288
|
+
|
|
289
|
+
// ══════════════════════════════════════════════════════
|
|
290
|
+
// YOUR TASKS (current user)
|
|
291
|
+
// ══════════════════════════════════════════════════════
|
|
292
|
+
if (currentUserCanonical && yourTasks) {
|
|
293
|
+
ln(`<h2>⭐ Your Tasks — ${e(currentUserCanonical)}</h2>`);
|
|
294
|
+
ln('<div class="person-section star">');
|
|
295
|
+
|
|
296
|
+
if (yourTasks.summary) ln(`<blockquote>${e(yourTasks.summary)}</blockquote>`);
|
|
297
|
+
|
|
298
|
+
// Owned tickets
|
|
299
|
+
const myTickets = dedupBy(
|
|
300
|
+
allTickets.filter(t => nameMatch(t.assignee, currentUserCanonical)),
|
|
301
|
+
t => t.ticket_id
|
|
302
|
+
);
|
|
303
|
+
if (myTickets.length > 0) {
|
|
304
|
+
ln(`<p><strong>🎫 Your Tickets:</strong> ${myTickets.map(t => `${e(t.ticket_id)} (${e((t.status || '?').replace(/_/g, ' '))})`).join(' · ')}</p>`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Todo items
|
|
308
|
+
const todoItems = dedupByDesc(yourTasks.tasks_todo || []);
|
|
309
|
+
const myActions = allActions.filter(ai =>
|
|
310
|
+
nameMatch(ai.assigned_to, currentUserCanonical) &&
|
|
311
|
+
(ai.status === 'todo' || ai.status === 'in_progress')
|
|
312
|
+
);
|
|
313
|
+
const allTodos = [...todoItems];
|
|
314
|
+
const todoDescKeys = new Set(allTodos.map(t => normalizeDesc(t.description)));
|
|
315
|
+
for (const ai of myActions) {
|
|
316
|
+
const dk = normalizeDesc(ai.description);
|
|
317
|
+
if (!todoDescKeys.has(dk)) { allTodos.push(ai); todoDescKeys.add(dk); }
|
|
318
|
+
}
|
|
319
|
+
if (allTodos.length > 0) {
|
|
320
|
+
ln('<h3>📌 To Do</h3><ul>');
|
|
321
|
+
for (const item of allTodos) {
|
|
322
|
+
const pri = priBadgeHtml(item.priority);
|
|
323
|
+
const conf = confBadgeHtml(item.confidence);
|
|
324
|
+
const source = item.source ? ` <em>(${e(item.source)})</em>` : '';
|
|
325
|
+
const ts = item.referenced_at ? ` @ ${tsHtml(item.referenced_at, item.source_segment)}` : '';
|
|
326
|
+
const blocker = item.blocked_by ? `<br> ⛔ <strong>Blocked by</strong>: ${e(item.blocked_by)}` : '';
|
|
327
|
+
ln(`<li><input type="checkbox" class="checkbox" disabled> ${e(item.description)}${pri}${conf}${source}${ts}${blocker}</li>`);
|
|
328
|
+
}
|
|
329
|
+
ln('</ul>');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// CRs assigned to user
|
|
333
|
+
const myCRs = dedupBy(
|
|
334
|
+
allCRs.filter(cr => nameMatch(cr.assigned_to, currentUserCanonical) && cr.status !== 'completed'),
|
|
335
|
+
cr => cr.id
|
|
336
|
+
);
|
|
337
|
+
if (myCRs.length > 0) {
|
|
338
|
+
ln('<h3>🔧 Your Change Requests</h3><ul>');
|
|
339
|
+
for (const cr of myCRs) {
|
|
340
|
+
const status = cr.status ? ` <code>${e(cr.status)}</code>` : '';
|
|
341
|
+
const pri = priBadgeHtml(cr.priority);
|
|
342
|
+
const where = cr.where?.file_path ? ` → <code>${e(cr.where.file_path)}</code>` : '';
|
|
343
|
+
ln(`<li><strong>${e(cr.id)}</strong>: ${e(cr.title || cr.what)}${status}${pri}${where}`);
|
|
344
|
+
if (cr.what && cr.what !== cr.title) ln(`<br> What: ${e(cr.what)}`);
|
|
345
|
+
if (cr.how) ln(`<br> How: ${e(cr.how)}`);
|
|
346
|
+
if (cr.why) ln(`<br> Why: ${e(cr.why)}`);
|
|
347
|
+
ln('</li>');
|
|
348
|
+
}
|
|
349
|
+
ln('</ul>');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Waiting on others
|
|
353
|
+
const waitingItems = dedupByDesc(yourTasks.tasks_waiting_on_others || []);
|
|
354
|
+
if (waitingItems.length > 0) {
|
|
355
|
+
ln('<h3>⏳ Waiting On Others</h3><ul>');
|
|
356
|
+
for (const w of waitingItems) {
|
|
357
|
+
const resolvedWho = resolve(w.waiting_on, clusterMap);
|
|
358
|
+
ln(`<li>⏳ ${e(w.description)} → waiting on <strong>${e(resolvedWho)}</strong></li>`);
|
|
359
|
+
}
|
|
360
|
+
ln('</ul>');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Decisions needed
|
|
364
|
+
const decisionItems = dedupByDesc(yourTasks.decisions_needed || []);
|
|
365
|
+
if (decisionItems.length > 0) {
|
|
366
|
+
ln('<h3>❓ Decisions Needed</h3><ul>');
|
|
367
|
+
for (const d of decisionItems) {
|
|
368
|
+
const resolvedWho = resolve(d.from_whom, clusterMap);
|
|
369
|
+
ln(`<li>${e(d.description)} → from <strong>${e(resolvedWho)}</strong></li>`);
|
|
370
|
+
}
|
|
371
|
+
ln('</ul>');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// User's blockers
|
|
375
|
+
const myBlockers = dedupBy(
|
|
376
|
+
allBlockers.filter(b => nameMatch(b.owner, currentUserCanonical)),
|
|
377
|
+
b => b.id
|
|
378
|
+
);
|
|
379
|
+
if (myBlockers.length > 0) {
|
|
380
|
+
ln('<h3>🚫 Your Blockers</h3><ul>');
|
|
381
|
+
for (const b of myBlockers) {
|
|
382
|
+
const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
|
|
383
|
+
const status = b.status ? ` (${e(b.status)})` : '';
|
|
384
|
+
const bConf = confBadgeHtml(b.confidence);
|
|
385
|
+
ln(`<li><strong>${e(b.id)}</strong>: ${e(b.description)}${e(env)}${status}${bConf}</li>`);
|
|
386
|
+
}
|
|
387
|
+
ln('</ul>');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
ln('</div>');
|
|
391
|
+
ln('<hr>');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ══════════════════════════════════════════════════════
|
|
395
|
+
// DETAILED TICKET ANALYSIS
|
|
396
|
+
// ══════════════════════════════════════════════════════
|
|
397
|
+
if (allTickets.length > 0) {
|
|
398
|
+
ln('<h2>🎫 Detailed Ticket Analysis</h2>');
|
|
399
|
+
for (const t of allTickets) {
|
|
400
|
+
const assignee = t.assignee ? resolve(t.assignee, clusterMap) : 'Unassigned';
|
|
401
|
+
const reviewer = t.reviewer ? resolve(t.reviewer, clusterMap) : null;
|
|
402
|
+
const status = (t.status || 'unknown').replace(/_/g, ' ');
|
|
403
|
+
const tConf = confBadgeHtml(t.confidence);
|
|
404
|
+
|
|
405
|
+
ln('<div class="card">');
|
|
406
|
+
ln(`<h3>${e(t.ticket_id)} — ${e(t.title || 'Untitled')}${tConf}</h3>`);
|
|
407
|
+
ln(`<blockquote><strong>Status</strong>: ${e(status)} | <strong>Assignee</strong>: ${e(assignee)}${reviewer ? ` | <strong>Reviewer</strong>: ${e(reviewer)}` : ''}</blockquote>`);
|
|
408
|
+
|
|
409
|
+
// Documented state
|
|
410
|
+
const ds = t.documented_state;
|
|
411
|
+
if (ds) {
|
|
412
|
+
ln('<h4>📄 Documented State</h4><ul>');
|
|
413
|
+
if (ds.source) ln(`<li><strong>Source</strong>: <code>${e(ds.source)}</code></li>`);
|
|
414
|
+
if (ds.plan_status) ln(`<li><strong>Plan Status</strong>: ${e(ds.plan_status)}</li>`);
|
|
415
|
+
if (ds.checklist_progress) ln(`<li><strong>Checklist</strong>: ${e(ds.checklist_progress)}</li>`);
|
|
416
|
+
if (ds.sub_tickets && ds.sub_tickets.length > 0) {
|
|
417
|
+
ln('<li><strong>Sub-tickets</strong>:<ul>');
|
|
418
|
+
for (const st of ds.sub_tickets) ln(`<li><strong>${e(st.id)}</strong> ${e(st.title)} — ${e(st.documented_status || '?')}</li>`);
|
|
419
|
+
ln('</ul></li>');
|
|
420
|
+
}
|
|
421
|
+
if (ds.open_blockers && ds.open_blockers.length > 0) {
|
|
422
|
+
ln('<li><strong>Documented Blockers</strong>:<ul>');
|
|
423
|
+
for (const ob of ds.open_blockers) ln(`<li>⚠️ ${e(ob)}</li>`);
|
|
424
|
+
ln('</ul></li>');
|
|
425
|
+
}
|
|
426
|
+
ln('</ul>');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Discussed state
|
|
430
|
+
const disc = t.discussed_state;
|
|
431
|
+
if (disc) {
|
|
432
|
+
ln('<h4>💬 Discussed in Call</h4>');
|
|
433
|
+
if (disc.summary) ln(`<p>${e(disc.summary)}</p>`);
|
|
434
|
+
if (disc.discrepancies && disc.discrepancies.length > 0) {
|
|
435
|
+
ln('<p><strong>⚡ Discrepancies (docs vs. call)</strong>:</p><ul>');
|
|
436
|
+
for (const d of disc.discrepancies) ln(`<li>⚡ ${e(d)}</li>`);
|
|
437
|
+
ln('</ul>');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Video segments
|
|
442
|
+
const vs = t.video_segments || [];
|
|
443
|
+
if (vs.length > 0) {
|
|
444
|
+
ln('<h4>🎬 Video Segments</h4><ul>');
|
|
445
|
+
for (const seg of vs) {
|
|
446
|
+
const start = seg.start_time || '?';
|
|
447
|
+
const end = seg.end_time || '?';
|
|
448
|
+
const segLabel = seg.source_segment ? ` <small>(Seg ${seg.source_segment})</small>` : '';
|
|
449
|
+
ln(`<li><code>${e(start)}</code> → <code>${e(end)}</code>${segLabel}: ${e(seg.description)}</li>`);
|
|
450
|
+
}
|
|
451
|
+
ln('</ul>');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Key quotes
|
|
455
|
+
const comments = t.comments || [];
|
|
456
|
+
if (comments.length > 0) {
|
|
457
|
+
ln('<h4>🗣️ Key Quotes</h4><ul>');
|
|
458
|
+
for (const cmt of comments) {
|
|
459
|
+
const speaker = cmt.speaker ? resolve(cmt.speaker, clusterMap) : 'Unknown';
|
|
460
|
+
const ts = cmt.timestamp ? `<code>${e(cmt.timestamp)}</code> ` : '';
|
|
461
|
+
ln(`<li>${ts}<strong>${e(speaker)}</strong>: "${e(cmt.text)}"</li>`);
|
|
462
|
+
}
|
|
463
|
+
ln('</ul>');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Code changes
|
|
467
|
+
const codeChanges = t.code_changes || [];
|
|
468
|
+
if (codeChanges.length > 0) {
|
|
469
|
+
ln('<h4>💻 Code Changes</h4><ul>');
|
|
470
|
+
for (const cc of codeChanges) {
|
|
471
|
+
const type = cc.type ? `[${e(cc.type)}] ` : '';
|
|
472
|
+
const pri = priBadgeHtml(cc.priority);
|
|
473
|
+
ln(`<li>${type}<code>${e(cc.file_path || '?')}</code>${pri}<br>${e(cc.description)}</li>`);
|
|
474
|
+
}
|
|
475
|
+
ln('</ul>');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
ln('</div>');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ══════════════════════════════════════════════════════
|
|
483
|
+
// ALL ACTION ITEMS
|
|
484
|
+
// ══════════════════════════════════════════════════════
|
|
485
|
+
if (allActions.length > 0) {
|
|
486
|
+
ln('<h2>📋 All Action Items</h2>');
|
|
487
|
+
ln('<table><tr><th>ID</th><th>Description</th><th>Assigned To</th><th>Status</th><th>Priority</th><th>Conf</th><th>Ref</th></tr>');
|
|
488
|
+
for (const ai of allActions) {
|
|
489
|
+
const assignee = ai.assigned_to ? resolve(ai.assigned_to, clusterMap) : '-';
|
|
490
|
+
const status = (ai.status || '?').replace(/_/g, ' ');
|
|
491
|
+
const pri = priBadgeHtml(ai.priority);
|
|
492
|
+
const conf = confBadgeHtml(ai.confidence);
|
|
493
|
+
const ref = [...(ai.related_tickets || []), ...(ai.related_changes || [])].join(', ') || '-';
|
|
494
|
+
ln(`<tr><td>${e(ai.id)}</td><td>${e(ai.description)}</td><td>${e(assignee)}</td><td>${e(status)}</td><td>${pri || '-'}</td><td>${conf || '-'}</td><td>${e(ref)}</td></tr>`);
|
|
495
|
+
}
|
|
496
|
+
ln('</table>');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ══════════════════════════════════════════════════════
|
|
500
|
+
// OTHER PARTICIPANTS
|
|
501
|
+
// ══════════════════════════════════════════════════════
|
|
502
|
+
const otherPeople = orderedPeople.filter(p => p !== currentUserCanonical);
|
|
503
|
+
if (otherPeople.length > 0) {
|
|
504
|
+
ln('<h2>👥 Other Participants</h2>');
|
|
505
|
+
for (const person of otherPeople) {
|
|
506
|
+
const personActions = allActions.filter(ai => nameMatch(ai.assigned_to, person));
|
|
507
|
+
const personCRs = dedupBy(allCRs.filter(cr => nameMatch(cr.assigned_to, person) && cr.status !== 'completed'), cr => cr.id);
|
|
508
|
+
const personBlockersOwned = dedupBy(allBlockers.filter(b => nameMatch(b.owner, person)), b => b.id);
|
|
509
|
+
const personTickets = dedupBy(allTickets.filter(t => nameMatch(t.assignee, person)), t => t.ticket_id);
|
|
510
|
+
|
|
511
|
+
const hasContent = personActions.length > 0 || personCRs.length > 0 || personBlockersOwned.length > 0 || personTickets.length > 0;
|
|
512
|
+
if (!hasContent) continue;
|
|
513
|
+
|
|
514
|
+
ln(`<div class="person-section"><h3>${e(person)}</h3>`);
|
|
515
|
+
|
|
516
|
+
if (personTickets.length > 0) {
|
|
517
|
+
ln(`<p><strong>🎫 Tickets:</strong> ${personTickets.map(t => `${e(t.ticket_id)} (${e((t.status || '?').replace(/_/g, ' '))})`).join(' · ')}</p>`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const actionableTodos = dedupByDesc(personActions.filter(ai => ai.status === 'todo' || ai.status === 'in_progress'));
|
|
521
|
+
if (actionableTodos.length > 0) {
|
|
522
|
+
ln('<strong>📌 To Do</strong><ul>');
|
|
523
|
+
for (const item of actionableTodos) {
|
|
524
|
+
const pri = priBadgeHtml(item.priority);
|
|
525
|
+
const ref = (item.related_tickets || []).length > 0 ? ` <em>(${item.related_tickets.join(', ')})</em>` : '';
|
|
526
|
+
ln(`<li><input type="checkbox" class="checkbox" disabled> ${e(item.description)}${pri}${ref}</li>`);
|
|
527
|
+
}
|
|
528
|
+
ln('</ul>');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (personCRs.length > 0) {
|
|
532
|
+
ln('<strong>🔧 Change Requests</strong><ul>');
|
|
533
|
+
for (const cr of personCRs) {
|
|
534
|
+
const status = cr.status ? ` <code>${e(cr.status)}</code>` : '';
|
|
535
|
+
const pri = priBadgeHtml(cr.priority);
|
|
536
|
+
ln(`<li><strong>${e(cr.id)}</strong>: ${e(cr.title || cr.what)}${status}${pri}</li>`);
|
|
537
|
+
}
|
|
538
|
+
ln('</ul>');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (personBlockersOwned.length > 0) {
|
|
542
|
+
ln('<strong>🚫 Blockers</strong><ul>');
|
|
543
|
+
for (const b of personBlockersOwned) {
|
|
544
|
+
const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
|
|
545
|
+
const status = b.status ? ` (${e(b.status)})` : '';
|
|
546
|
+
ln(`<li><strong>${e(b.id)}</strong>: ${e(b.description)}${e(env)}${status}</li>`);
|
|
547
|
+
}
|
|
548
|
+
ln('</ul>');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
ln('</div>');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ══════════════════════════════════════════════════════
|
|
556
|
+
// TEAM / EXTERNAL BLOCKERS
|
|
557
|
+
// ══════════════════════════════════════════════════════
|
|
558
|
+
const teamBlockers = dedupBy(allBlockers.filter(b => !orderedPeople.some(p => nameMatch(b.owner, p))), b => b.id);
|
|
559
|
+
if (teamBlockers.length > 0) {
|
|
560
|
+
ln('<h2>🚫 Team / External Blockers</h2><ul>');
|
|
561
|
+
for (const b of teamBlockers) {
|
|
562
|
+
const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
|
|
563
|
+
const status = b.status ? ` (${e(b.status)})` : '';
|
|
564
|
+
const type = b.type ? ` <em>[${e(b.type.replace(/_/g, ' '))}]</em>` : '';
|
|
565
|
+
const owner = b.owner ? ` — ${e(b.owner)}` : '';
|
|
566
|
+
ln(`<li><strong>${e(b.id)}</strong>${owner}: ${e(b.description)}${e(env)}${status}${type}</li>`);
|
|
567
|
+
}
|
|
568
|
+
ln('</ul><hr>');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ══════════════════════════════════════════════════════
|
|
572
|
+
// SCOPE CHANGES
|
|
573
|
+
// ══════════════════════════════════════════════════════
|
|
574
|
+
if (allScope.length > 0) {
|
|
575
|
+
ln('<h2>🔀 Scope Changes</h2><ul>');
|
|
576
|
+
for (const sc of allScope) {
|
|
577
|
+
const icon = { added: '➕', removed: '➖', deferred: '⏸️', approach_changed: '🔄', ownership_changed: '👤', requirements_changed: '📋' }[sc.type] || '🔀';
|
|
578
|
+
const decidedBy = sc.decided_by ? resolve(sc.decided_by, clusterMap) : null;
|
|
579
|
+
const scConf = confBadgeHtml(sc.confidence);
|
|
580
|
+
ln(`<li>${icon} <strong>${e(sc.id)}</strong> (${e((sc.type || '').replace(/_/g, ' '))}): ${e(sc.new_scope)}${scConf}`);
|
|
581
|
+
if (sc.original_scope && sc.original_scope !== 'not documented') ln(`<br> Was: ${e(sc.original_scope)}`);
|
|
582
|
+
if (sc.reason) ln(`<br> Reason: ${e(sc.reason)}`);
|
|
583
|
+
if (decidedBy) ln(`<br> Decided by: ${e(decidedBy)}`);
|
|
584
|
+
ln('</li>');
|
|
585
|
+
}
|
|
586
|
+
ln('</ul><hr>');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ══════════════════════════════════════════════════════
|
|
590
|
+
// ALL CHANGE REQUESTS
|
|
591
|
+
// ══════════════════════════════════════════════════════
|
|
592
|
+
if (allCRs.length > 0) {
|
|
593
|
+
ln('<h2>🔧 All Change Requests</h2>');
|
|
594
|
+
ln('<table><tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th><th>Priority</th><th>Conf</th><th>Assignee</th><th>File</th></tr>');
|
|
595
|
+
for (const cr of allCRs) {
|
|
596
|
+
const assignee = cr.assigned_to ? resolve(cr.assigned_to, clusterMap) : '-';
|
|
597
|
+
const status = cr.status || '-';
|
|
598
|
+
const conf = confBadgeHtml(cr.confidence);
|
|
599
|
+
const type = cr.type || '-';
|
|
600
|
+
const file = cr.where?.file_path ? `<code>${e(cr.where.file_path)}</code>` : '-';
|
|
601
|
+
const pri = priBadgeHtml(cr.priority);
|
|
602
|
+
ln(`<tr><td>${e(cr.id)}</td><td>${e(cr.title || cr.what || '-')}</td><td>${e(type)}</td><td>${e(status)}</td><td>${pri || '-'}</td><td>${conf || '-'}</td><td>${e(assignee)}</td><td>${file}</td></tr>`);
|
|
603
|
+
}
|
|
604
|
+
ln('</table>');
|
|
605
|
+
|
|
606
|
+
// Detailed breakdown
|
|
607
|
+
ln('<details><summary>📖 Change Request Details</summary>');
|
|
608
|
+
for (const cr of allCRs) {
|
|
609
|
+
const assignee = cr.assigned_to ? resolve(cr.assigned_to, clusterMap) : '?';
|
|
610
|
+
const status = cr.status ? ` <code>${e(cr.status)}</code>` : '';
|
|
611
|
+
ln(`<div class="card"><strong>${e(cr.id)}</strong>: ${e(cr.title || cr.what)}${status} → ${e(assignee)}`);
|
|
612
|
+
if (cr.what && cr.what !== cr.title) ln(`<br>What: ${e(cr.what)}`);
|
|
613
|
+
if (cr.how) ln(`<br>How: ${e(cr.how)}`);
|
|
614
|
+
if (cr.why) ln(`<br>Why: ${e(cr.why)}`);
|
|
615
|
+
if (cr.where?.file_path) ln(`<br>File: <code>${e(cr.where.file_path)}</code>`);
|
|
616
|
+
ln('</div>');
|
|
617
|
+
}
|
|
618
|
+
ln('</details><hr>');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ══════════════════════════════════════════════════════
|
|
622
|
+
// FILE REFERENCES
|
|
623
|
+
// ══════════════════════════════════════════════════════
|
|
624
|
+
if (allFiles.length > 0) {
|
|
625
|
+
const actionableFiles = allFiles.filter(f => f.role && !['reference_only', 'source_of_truth'].includes(f.role));
|
|
626
|
+
const referenceFiles = allFiles.filter(f => !f.role || ['reference_only', 'source_of_truth'].includes(f.role));
|
|
627
|
+
|
|
628
|
+
if (actionableFiles.length > 0) {
|
|
629
|
+
ln('<h2>📂 Files Requiring Action</h2>');
|
|
630
|
+
ln('<table><tr><th>File</th><th>Role</th><th>Type</th><th>Tickets</th><th>Changes</th></tr>');
|
|
631
|
+
for (const f of actionableFiles) {
|
|
632
|
+
const role = (f.role || '').replace(/_/g, ' ');
|
|
633
|
+
const type = (f.file_type || '').replace(/_/g, ' ');
|
|
634
|
+
const tickets = (f.mentioned_in_tickets || []).join(', ') || '-';
|
|
635
|
+
const changes = (f.mentioned_in_changes || []).join(', ') || '-';
|
|
636
|
+
ln(`<tr><td>${e(f.file_name)}</td><td>${e(role)}</td><td>${e(type)}</td><td>${e(tickets)}</td><td>${e(changes)}</td></tr>`);
|
|
637
|
+
}
|
|
638
|
+
ln('</table>');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (referenceFiles.length > 0) {
|
|
642
|
+
ln(`<details><summary>📎 Reference Files (${referenceFiles.length})</summary>`);
|
|
643
|
+
ln('<table><tr><th>File</th><th>Type</th><th>Tickets</th><th>Notes</th></tr>');
|
|
644
|
+
for (const f of referenceFiles) {
|
|
645
|
+
const type = (f.file_type || '').replace(/_/g, ' ');
|
|
646
|
+
const tickets = (f.mentioned_in_tickets || []).join(', ') || '-';
|
|
647
|
+
const notes = f.notes ? e(f.notes.slice(0, 80)) + (f.notes.length > 80 ? '...' : '') : '-';
|
|
648
|
+
ln(`<tr><td>${e(f.file_name)}</td><td>${e(type)}</td><td>${e(tickets)}</td><td>${notes}</td></tr>`);
|
|
649
|
+
}
|
|
650
|
+
ln('</table></details>');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── Footer ──
|
|
655
|
+
const genTs = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
656
|
+
const stats = [
|
|
657
|
+
`${allTickets.length} tickets`,
|
|
658
|
+
`${allCRs.length} CRs`,
|
|
659
|
+
`${allActions.length} actions`,
|
|
660
|
+
`${allBlockers.length} blockers`,
|
|
661
|
+
`${allScope.length} scope changes`,
|
|
662
|
+
`${allFiles.length} files`,
|
|
663
|
+
].join(' · ');
|
|
664
|
+
ln(`<div class="footer">Generated ${genTs} — AI-compiled final result | ${stats}</div>`);
|
|
665
|
+
|
|
666
|
+
ln(`<script>${JS_SCRIPT}</script>`);
|
|
667
|
+
ln('</body></html>');
|
|
668
|
+
|
|
669
|
+
return h.join('\n');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
module.exports = { renderResultsHtml };
|