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.
Files changed (46) hide show
  1. package/.env.example +38 -0
  2. package/ARCHITECTURE.md +99 -3
  3. package/EXPLORATION.md +148 -89
  4. package/QUICK_START.md +5 -2
  5. package/README.md +51 -7
  6. package/bin/taskex.js +11 -4
  7. package/package.json +38 -5
  8. package/prompt.json +2 -2
  9. package/src/config.js +52 -3
  10. package/src/logger.js +7 -4
  11. package/src/modes/focused-reanalysis.js +2 -1
  12. package/src/modes/progress-updater.js +1 -1
  13. package/src/phases/_shared.js +43 -0
  14. package/src/phases/compile.js +101 -0
  15. package/src/phases/deep-dive.js +118 -0
  16. package/src/phases/discover.js +178 -0
  17. package/src/phases/init.js +199 -0
  18. package/src/phases/output.js +238 -0
  19. package/src/phases/process-media.js +633 -0
  20. package/src/phases/services.js +104 -0
  21. package/src/phases/summary.js +86 -0
  22. package/src/pipeline.js +432 -1464
  23. package/src/renderers/docx.js +531 -0
  24. package/src/renderers/html.js +672 -0
  25. package/src/renderers/markdown.js +15 -183
  26. package/src/renderers/pdf.js +90 -0
  27. package/src/renderers/shared.js +215 -0
  28. package/src/schemas/analysis-compiled.schema.json +381 -0
  29. package/src/schemas/analysis-segment.schema.json +380 -0
  30. package/src/services/doc-parser.js +346 -0
  31. package/src/services/gemini.js +118 -45
  32. package/src/services/video.js +123 -8
  33. package/src/utils/adaptive-budget.js +6 -4
  34. package/src/utils/checkpoint.js +2 -1
  35. package/src/utils/cli.js +132 -111
  36. package/src/utils/colors.js +83 -0
  37. package/src/utils/confidence-filter.js +139 -0
  38. package/src/utils/diff-engine.js +2 -1
  39. package/src/utils/global-config.js +6 -5
  40. package/src/utils/health-dashboard.js +11 -9
  41. package/src/utils/json-parser.js +4 -2
  42. package/src/utils/learning-loop.js +3 -2
  43. package/src/utils/progress-bar.js +286 -0
  44. package/src/utils/quality-gate.js +10 -8
  45. package/src/utils/retry.js +3 -1
  46. 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>&nbsp;&nbsp;⛔ <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>&nbsp;&nbsp;What: ${e(cr.what)}`);
345
+ if (cr.how) ln(`<br>&nbsp;&nbsp;How: ${e(cr.how)}`);
346
+ if (cr.why) ln(`<br>&nbsp;&nbsp;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>&nbsp;&nbsp;Was: ${e(sc.original_scope)}`);
582
+ if (sc.reason) ln(`<br>&nbsp;&nbsp;Reason: ${e(sc.reason)}`);
583
+ if (decidedBy) ln(`<br>&nbsp;&nbsp;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 };