@teamclaws/teamclaw 2026.3.26-2 → 2026.4.2-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.
Files changed (46) hide show
  1. package/README.md +52 -8
  2. package/cli.mjs +538 -224
  3. package/index.ts +76 -27
  4. package/openclaw.plugin.json +53 -28
  5. package/package.json +5 -2
  6. package/skills/teamclaw/SKILL.md +213 -0
  7. package/skills/teamclaw/references/api-quick-ref.md +117 -0
  8. package/skills/teamclaw-setup/SKILL.md +81 -0
  9. package/skills/teamclaw-setup/references/install-modes.md +136 -0
  10. package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
  11. package/src/config.ts +44 -16
  12. package/src/controller/controller-capacity.ts +2 -2
  13. package/src/controller/controller-service.ts +193 -47
  14. package/src/controller/controller-tools.ts +102 -2
  15. package/src/controller/delivery-report.ts +563 -0
  16. package/src/controller/http-server.ts +1907 -172
  17. package/src/controller/kickoff-orchestrator.ts +292 -0
  18. package/src/controller/managed-gateway-process.ts +330 -0
  19. package/src/controller/orchestration-manifest.ts +69 -1
  20. package/src/controller/preview-manager.ts +676 -0
  21. package/src/controller/prompt-injector.ts +116 -67
  22. package/src/controller/role-inference.ts +41 -0
  23. package/src/controller/websocket.ts +3 -1
  24. package/src/controller/worker-provisioning.ts +429 -74
  25. package/src/discovery.ts +1 -1
  26. package/src/git-collaboration.ts +198 -47
  27. package/src/identity.ts +12 -2
  28. package/src/interaction-contracts.ts +179 -3
  29. package/src/networking.ts +99 -0
  30. package/src/openclaw-workspace.ts +478 -11
  31. package/src/prompt-policy.ts +381 -0
  32. package/src/roles.ts +37 -36
  33. package/src/state.ts +40 -1
  34. package/src/task-executor.ts +282 -78
  35. package/src/types.ts +150 -7
  36. package/src/ui/app.js +1403 -175
  37. package/src/ui/assets/teamclaw-app-icon.png +0 -0
  38. package/src/ui/index.html +122 -40
  39. package/src/ui/style.css +829 -143
  40. package/src/worker/http-handler.ts +40 -4
  41. package/src/worker/prompt-injector.ts +9 -38
  42. package/src/worker/skill-installer.ts +2 -2
  43. package/src/worker/tools.ts +31 -5
  44. package/src/worker/worker-service.ts +49 -8
  45. package/src/workspace-browser.ts +20 -7
  46. package/src/controller/local-worker-manager.ts +0 -533
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Delivery Report — auto-generated project completion report for TeamClaw sessions.
3
+ *
4
+ * When all tasks in a controller session finish, this module aggregates
5
+ * task results, deliverables, previews, and timeline into a self-contained
6
+ * HTML page that can be shared via URL or pushed to notification channels.
7
+ */
8
+
9
+ import type {
10
+ TeamState,
11
+ TaskInfo,
12
+ ControllerRunInfo,
13
+ WorkerTaskResultDeliverable,
14
+ } from "../types.js";
15
+
16
+ // ── Report data model ────────────────────────────────────────────────
17
+
18
+ export type DeliveryReportPhase = {
19
+ taskId: string;
20
+ title: string;
21
+ role: string;
22
+ status: string;
23
+ durationMs: number;
24
+ summary: string;
25
+ keyPoints: string[];
26
+ error?: string;
27
+ };
28
+
29
+ export type DeliveryReportDeliverable = {
30
+ taskId: string;
31
+ kind: string;
32
+ path: string;
33
+ summary: string;
34
+ artifactType?: string;
35
+ previewUrl?: string;
36
+ previewError?: string;
37
+ };
38
+
39
+ export type DeliveryReport = {
40
+ id: string;
41
+ sessionKey: string;
42
+ generatedAt: number;
43
+
44
+ // Header
45
+ projectName: string;
46
+ requirementSummary: string;
47
+ status: "completed" | "partial" | "failed";
48
+ totalDurationMs: number;
49
+
50
+ // Pipeline
51
+ phases: DeliveryReportPhase[];
52
+
53
+ // Deliverables
54
+ deliverables: DeliveryReportDeliverable[];
55
+
56
+ // Highlights
57
+ keyPoints: string[];
58
+ blockers: string[];
59
+ followUps: string[];
60
+ notes: string;
61
+
62
+ // Meta
63
+ runCount: number;
64
+ taskCount: number;
65
+ rolesUsed: string[];
66
+ };
67
+
68
+ // ── Report generation ────────────────────────────────────────────────
69
+
70
+ export function generateDeliveryReport(
71
+ sessionKey: string,
72
+ state: TeamState,
73
+ normalizeSessionKey: (key: unknown) => string,
74
+ ): DeliveryReport | null {
75
+ const normalizedKey = normalizeSessionKey(sessionKey);
76
+
77
+ // Collect all controller runs for this session
78
+ const runs = Object.values(state.controllerRuns)
79
+ .filter((r) => normalizeSessionKey(r.sessionKey) === normalizedKey)
80
+ .sort((a, b) => a.createdAt - b.createdAt);
81
+
82
+ if (runs.length === 0) return null;
83
+
84
+ // Collect all task IDs across all runs
85
+ const allTaskIds = Array.from(new Set(runs.flatMap((r) => r.createdTaskIds)));
86
+ const tasks = allTaskIds
87
+ .map((id) => state.tasks[id])
88
+ .filter((t): t is TaskInfo => !!t)
89
+ .sort((a, b) => a.createdAt - b.createdAt);
90
+
91
+ if (tasks.length === 0) return null;
92
+
93
+ // Determine overall status
94
+ const failedTasks = tasks.filter((t) => t.status === "failed");
95
+ const completedTasks = tasks.filter((t) => t.status === "completed");
96
+ const activeTasks = tasks.filter((t) =>
97
+ t.status === "in_progress" || t.status === "pending" || t.status === "assigned",
98
+ );
99
+ let status: DeliveryReport["status"] = "completed";
100
+ if (failedTasks.length > 0 && completedTasks.length === 0) status = "failed";
101
+ else if (activeTasks.length > 0 || failedTasks.length > 0) status = "partial";
102
+
103
+ // Find the best project name and summary
104
+ const latestManifest = [...runs].reverse().find((r) => r.manifest)?.manifest;
105
+ const firstManifest = runs.find((r) => r.manifest)?.manifest;
106
+ const projectName =
107
+ latestManifest?.projectName || firstManifest?.projectName || runs[0].projectDir || "Untitled Project";
108
+ const requirementSummary =
109
+ firstManifest?.requirementSummary || runs[0].request || "";
110
+
111
+ // Timeline
112
+ const sessionStart = runs[0].createdAt;
113
+ const lastCompletion = Math.max(...tasks.map((t) => t.completedAt ?? t.updatedAt));
114
+ const totalDurationMs = lastCompletion - sessionStart;
115
+
116
+ // Build phases
117
+ const phases: DeliveryReportPhase[] = tasks.map((task) => ({
118
+ taskId: task.id,
119
+ title: task.title,
120
+ role: task.assignedRole ?? "unknown",
121
+ status: task.status,
122
+ durationMs: (task.completedAt ?? task.updatedAt) - task.createdAt,
123
+ summary: task.resultContract?.summary ?? task.result?.slice(0, 200) ?? "",
124
+ keyPoints: task.resultContract?.keyPoints ?? [],
125
+ error: task.error,
126
+ }));
127
+
128
+ // Collect deliverables
129
+ const deliverables: DeliveryReportDeliverable[] = [];
130
+ for (const task of tasks) {
131
+ if (!task.resultContract?.deliverables) continue;
132
+ for (const [di, d] of task.resultContract.deliverables.entries()) {
133
+ const preview = resolvePreviewInfo(d, task.id, di, state);
134
+ deliverables.push({
135
+ taskId: task.id,
136
+ kind: d.kind,
137
+ path: d.value,
138
+ summary: d.summary ?? "",
139
+ artifactType: d.artifactType,
140
+ previewUrl: preview.url,
141
+ previewError: preview.error,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Aggregate highlights
147
+ const keyPoints = tasks.flatMap((t) => t.resultContract?.keyPoints ?? []);
148
+ const blockers = tasks.flatMap((t) => t.resultContract?.blockers ?? []);
149
+ const followUps = tasks.flatMap((t) =>
150
+ (t.resultContract?.followUps ?? []).map(
151
+ (f) => `${f.type}${f.targetRole ? ` (${f.targetRole})` : ""}: ${f.reason}`,
152
+ ),
153
+ );
154
+ const notes = latestManifest?.notes ?? "";
155
+
156
+ const rolesUsed = Array.from(new Set(tasks.map((t) => t.assignedRole).filter(Boolean) as string[]));
157
+
158
+ return {
159
+ id: `report-${normalizedKey}`,
160
+ sessionKey: normalizedKey,
161
+ generatedAt: Date.now(),
162
+ projectName,
163
+ requirementSummary,
164
+ status,
165
+ totalDurationMs,
166
+ phases,
167
+ deliverables,
168
+ keyPoints,
169
+ blockers,
170
+ followUps,
171
+ notes,
172
+ runCount: runs.length,
173
+ taskCount: tasks.length,
174
+ rolesUsed,
175
+ };
176
+ }
177
+
178
+ function isPreviewableArtifactType(d: WorkerTaskResultDeliverable): boolean {
179
+ return d.artifactType === "web-app" || d.artifactType === "static-site" || d.artifactType === "rest-api";
180
+ }
181
+
182
+ function resolvePreviewInfo(
183
+ deliverable: WorkerTaskResultDeliverable,
184
+ taskId: string,
185
+ deliverableIndex: number,
186
+ state: TeamState,
187
+ ): { url?: string; error?: string } {
188
+ if (deliverable.liveUrl) return { url: deliverable.liveUrl };
189
+
190
+ // Non-previewable deliverables (plain files, notes, commands) should never
191
+ // show preview errors — they don't have previews to fail.
192
+ if (!isPreviewableArtifactType(deliverable)) return {};
193
+
194
+ // Find preview record for this specific deliverable
195
+ const previewId = `preview-${taskId}-${deliverableIndex}`;
196
+ const exact = (state.previews ?? {})[previewId];
197
+ if (exact) {
198
+ if (exact.status === "healthy") {
199
+ const url = deliverable.artifactType === "rest-api" && exact.previewReadyPath && exact.previewReadyPath !== "/"
200
+ ? exact.liveUrl.replace(/\/$/, "") + exact.previewReadyPath
201
+ : exact.liveUrl;
202
+ return { url };
203
+ }
204
+ if (exact.status === "failed") return { error: exact.lastError ?? "Preview failed" };
205
+ if (exact.status === "stopped") return { error: "Preview stopped" };
206
+ return { error: "Preview is still starting…" };
207
+ }
208
+ // Fallback: find any healthy preview for this task
209
+ const previews = Object.values(state.previews ?? {});
210
+ const match = previews.find((p) => p.taskId === taskId && p.status === "healthy");
211
+ if (match) {
212
+ const url = deliverable.artifactType === "rest-api" && match.previewReadyPath && match.previewReadyPath !== "/"
213
+ ? match.liveUrl.replace(/\/$/, "") + match.previewReadyPath
214
+ : match.liveUrl;
215
+ return { url };
216
+ }
217
+ const failed = previews.find((p) => p.taskId === taskId && p.status === "failed");
218
+ if (failed) return { error: failed.lastError ?? "Preview failed" };
219
+ return {};
220
+ }
221
+
222
+ // ── Session completion detection ─────────────────────────────────────
223
+
224
+ export function isSessionComplete(
225
+ sessionKey: string,
226
+ state: TeamState,
227
+ normalizeSessionKey: (key: unknown) => string,
228
+ ): boolean {
229
+ const normalizedKey = normalizeSessionKey(sessionKey);
230
+ const runs = Object.values(state.controllerRuns)
231
+ .filter((r) => normalizeSessionKey(r.sessionKey) === normalizedKey)
232
+ .sort((a, b) => b.createdAt - a.createdAt);
233
+
234
+ if (runs.length === 0) return false;
235
+
236
+ // Collect all tasks for this session
237
+ const taskIds = new Set(runs.flatMap((r) => r.createdTaskIds));
238
+ if (taskIds.size === 0) return false;
239
+
240
+ // Check if any task is still active
241
+ for (const taskId of taskIds) {
242
+ const task = state.tasks[taskId];
243
+ if (!task) continue;
244
+ if (task.status !== "completed" && task.status !== "failed") {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ // Check if the latest run still has active deferred tasks
250
+ const latestWithManifest = runs.find((r) => r.manifest);
251
+ if (latestWithManifest?.manifest?.deferredTasks?.length) {
252
+ // There are deferred tasks — a follow-up run should advance them.
253
+ // Only consider complete if the latest run also says requirementFullyComplete.
254
+ if (!latestWithManifest.manifest.requirementFullyComplete) {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ // Check if any run is still actively executing
260
+ const activeRun = runs.find((r) => r.status === "pending" || r.status === "running");
261
+ if (activeRun) return false;
262
+
263
+ return true;
264
+ }
265
+
266
+ // ── HTML rendering ───────────────────────────────────────────────────
267
+
268
+ function escapeHtml(text: string): string {
269
+ return text
270
+ .replace(/&/g, "&")
271
+ .replace(/</g, "&lt;")
272
+ .replace(/>/g, "&gt;")
273
+ .replace(/"/g, "&quot;");
274
+ }
275
+
276
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"]);
277
+
278
+ function isImagePath(filePath: string): boolean {
279
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
280
+ return IMAGE_EXTENSIONS.has(ext);
281
+ }
282
+
283
+ function formatDuration(ms: number): string {
284
+ if (ms < 1000) return "<1s";
285
+ const totalSec = Math.floor(ms / 1000);
286
+ if (totalSec < 60) return `${totalSec}s`;
287
+ const min = Math.floor(totalSec / 60);
288
+ const sec = totalSec % 60;
289
+ if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
290
+ const hr = Math.floor(min / 60);
291
+ const remMin = min % 60;
292
+ return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
293
+ }
294
+
295
+ const STATUS_EMOJI: Record<string, string> = {
296
+ completed: "✅",
297
+ failed: "❌",
298
+ partial: "⚠️",
299
+ in_progress: "🔄",
300
+ pending: "⏳",
301
+ assigned: "📋",
302
+ blocked: "🚫",
303
+ };
304
+
305
+ export function renderReportHtml(report: DeliveryReport): string {
306
+ const statusEmoji = STATUS_EMOJI[report.status] ?? "❓";
307
+ const statusLabel =
308
+ report.status === "completed"
309
+ ? "Completed"
310
+ : report.status === "failed"
311
+ ? "Failed"
312
+ : "Partial";
313
+
314
+ const phasesHtml = report.phases
315
+ .map((phase) => {
316
+ const emoji = STATUS_EMOJI[phase.status] ?? "❓";
317
+ const duration = formatDuration(phase.durationMs);
318
+ const errorHtml = phase.error
319
+ ? `<div class="phase-error">Error: ${escapeHtml(phase.error.slice(0, 200))}</div>`
320
+ : "";
321
+ const keyPointsHtml =
322
+ phase.keyPoints.length > 0
323
+ ? `<ul class="phase-keypoints">${phase.keyPoints.map((kp) => `<li>${escapeHtml(kp)}</li>`).join("")}</ul>`
324
+ : "";
325
+ return `
326
+ <div class="phase-card phase-${phase.status}">
327
+ <div class="phase-header">
328
+ <span class="phase-emoji">${emoji}</span>
329
+ <span class="phase-title">${escapeHtml(phase.title)}</span>
330
+ <span class="phase-role">${escapeHtml(phase.role)}</span>
331
+ <span class="phase-duration">${duration}</span>
332
+ </div>
333
+ <div class="phase-summary">${escapeHtml(phase.summary)}</div>
334
+ ${errorHtml}
335
+ ${keyPointsHtml}
336
+ </div>`;
337
+ })
338
+ .join("\n");
339
+
340
+ const deliverablesHtml = report.deliverables
341
+ .map((d) => {
342
+ const kindIcon =
343
+ d.artifactType === "rest-api"
344
+ ? "🔌"
345
+ : d.artifactType === "web-app" || d.artifactType === "static-site"
346
+ ? "🌐"
347
+ : d.artifactType === "document"
348
+ ? "📝"
349
+ : d.kind === "directory"
350
+ ? "📁"
351
+ : d.kind === "command"
352
+ ? "💻"
353
+ : isImagePath(d.path)
354
+ ? "🖼️"
355
+ : "📄";
356
+ let previewHtml = "";
357
+ if (d.previewUrl) {
358
+ const previewLabel = d.artifactType === "rest-api"
359
+ ? "📖 Open API Documentation"
360
+ : "🔗 Open Live Preview";
361
+ previewHtml = `<div class="deliverable-preview">
362
+ <a href="${escapeHtml(d.previewUrl)}" target="_blank" class="preview-link">${previewLabel}</a>
363
+ <iframe src="${escapeHtml(d.previewUrl)}" class="preview-iframe" loading="lazy" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
364
+ </div>`;
365
+ } else if (d.previewError) {
366
+ previewHtml = `<div class="preview-error">⚠️ ${escapeHtml(d.previewError)}</div>`;
367
+ } else if (d.kind === "command") {
368
+ previewHtml = `<div class="deliverable-run-hint">💡 Run: <code>${escapeHtml(d.path)}</code></div>`;
369
+ } else if (d.artifactType === "document") {
370
+ previewHtml = `<div class="deliverable-doc-hint">📖 Document — view in workspace at <code>${escapeHtml(d.path)}</code></div>`;
371
+ } else if (isImagePath(d.path)) {
372
+ // Serve image via workspace file endpoint if available
373
+ previewHtml = `<div class="deliverable-image-hint">🖼️ Image file — open from workspace</div>`;
374
+ }
375
+ return `
376
+ <div class="deliverable-card">
377
+ <div class="deliverable-header">
378
+ <span class="deliverable-icon">${kindIcon}</span>
379
+ <span class="deliverable-path">${escapeHtml(d.path)}</span>
380
+ </div>
381
+ <div class="deliverable-summary">${escapeHtml(d.summary)}</div>
382
+ ${previewHtml}
383
+ </div>`;
384
+ })
385
+ .join("\n");
386
+
387
+ const keyPointsHtml =
388
+ report.keyPoints.length > 0
389
+ ? `<section class="section">
390
+ <h2>💡 Key Points</h2>
391
+ <ul>${report.keyPoints.map((kp) => `<li>${escapeHtml(kp)}</li>`).join("")}</ul>
392
+ </section>`
393
+ : "";
394
+
395
+ const blockersHtml =
396
+ report.blockers.length > 0
397
+ ? `<section class="section section-warning">
398
+ <h2>🚧 Blockers</h2>
399
+ <ul>${report.blockers.map((b) => `<li>${escapeHtml(b)}</li>`).join("")}</ul>
400
+ </section>`
401
+ : "";
402
+
403
+ const followUpsHtml =
404
+ report.followUps.length > 0
405
+ ? `<section class="section">
406
+ <h2>📌 Follow-ups</h2>
407
+ <ul>${report.followUps.map((f) => `<li>${escapeHtml(f)}</li>`).join("")}</ul>
408
+ </section>`
409
+ : "";
410
+
411
+ const notesHtml = report.notes
412
+ ? `<section class="section"><h2>📝 Notes</h2><p>${escapeHtml(report.notes)}</p></section>`
413
+ : "";
414
+
415
+ return `<!DOCTYPE html>
416
+ <html lang="en">
417
+ <head>
418
+ <meta charset="UTF-8">
419
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
420
+ <title>TeamClaw Report — ${escapeHtml(report.projectName)}</title>
421
+ <style>
422
+ :root {
423
+ --bg: #f8f9fa; --card: #fff; --border: #e2e8f0; --text: #1a202c;
424
+ --muted: #718096; --accent: #3182ce; --success: #38a169; --danger: #e53e3e;
425
+ --warning: #d69e2e; --radius: 10px; --shadow: 0 1px 3px rgba(0,0,0,.08);
426
+ }
427
+ * { box-sizing: border-box; margin: 0; padding: 0; }
428
+ body {
429
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
430
+ background: var(--bg); color: var(--text); line-height: 1.6;
431
+ max-width: 900px; margin: 0 auto; padding: 24px 16px;
432
+ }
433
+ .header {
434
+ background: linear-gradient(135deg, #2d3748, #1a365d);
435
+ color: #fff; border-radius: var(--radius); padding: 32px; margin-bottom: 24px;
436
+ }
437
+ .header h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
438
+ .header-meta { display: flex; gap: 16px; flex-wrap: wrap; font-size: .9rem; opacity: .85; }
439
+ .header-meta span { display: flex; align-items: center; gap: 4px; }
440
+ .requirement {
441
+ background: rgba(255,255,255,.1); border-radius: 6px;
442
+ padding: 12px; margin-top: 16px; font-size: .9rem; line-height: 1.5;
443
+ }
444
+ .section { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
445
+ padding: 20px; margin-bottom: 16px; box-shadow: var(--shadow); }
446
+ .section h2 { font-size: 1.1rem; margin-bottom: 12px; }
447
+ .section ul { padding-left: 20px; }
448
+ .section li { margin-bottom: 4px; font-size: .9rem; }
449
+ .section-warning { border-left: 4px solid var(--warning); }
450
+
451
+ /* Pipeline */
452
+ .pipeline { display: flex; flex-direction: column; gap: 8px; }
453
+ .phase-card {
454
+ background: var(--card); border: 1px solid var(--border); border-radius: 8px;
455
+ padding: 14px 16px; box-shadow: var(--shadow); position: relative;
456
+ }
457
+ .phase-card.phase-completed { border-left: 4px solid var(--success); }
458
+ .phase-card.phase-failed { border-left: 4px solid var(--danger); }
459
+ .phase-card.phase-in_progress { border-left: 4px solid var(--accent); }
460
+ .phase-header {
461
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px;
462
+ }
463
+ .phase-title { font-weight: 600; flex: 1; }
464
+ .phase-role {
465
+ background: #edf2f7; color: var(--muted); font-size: .75rem; padding: 2px 8px;
466
+ border-radius: 12px; text-transform: uppercase; letter-spacing: .5px;
467
+ }
468
+ .phase-duration { font-size: .8rem; color: var(--muted); }
469
+ .phase-summary { font-size: .85rem; color: #4a5568; }
470
+ .phase-error { font-size: .85rem; color: var(--danger); margin-top: 6px; }
471
+ .phase-keypoints { font-size: .8rem; color: var(--muted); margin-top: 6px; padding-left: 18px; }
472
+
473
+ /* Deliverables */
474
+ .deliverable-card {
475
+ background: var(--card); border: 1px solid var(--border); border-radius: 8px;
476
+ padding: 16px; margin-bottom: 12px; box-shadow: var(--shadow);
477
+ }
478
+ .deliverable-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
479
+ .deliverable-icon { font-size: 1.3rem; }
480
+ .deliverable-path { font-family: "SF Mono", Monaco, monospace; font-size: .85rem; color: var(--accent); }
481
+ .deliverable-summary { font-size: .85rem; color: #4a5568; }
482
+ .deliverable-preview { margin-top: 12px; }
483
+ .preview-link {
484
+ display: inline-block; margin-bottom: 8px; font-size: .85rem;
485
+ color: var(--accent); text-decoration: none; font-weight: 500;
486
+ }
487
+ .preview-link:hover { text-decoration: underline; }
488
+ .preview-iframe {
489
+ width: 100%; height: 400px; border: 1px solid var(--border);
490
+ border-radius: 6px; background: #fff;
491
+ }
492
+ .preview-error {
493
+ margin-top: 8px; padding: 10px 14px; background: #fff5f5; border: 1px solid #fed7d7;
494
+ border-radius: 6px; font-size: .85rem; color: var(--danger);
495
+ }
496
+ .deliverable-run-hint {
497
+ margin-top: 8px; padding: 8px 12px; background: #ebf8ff; border: 1px solid #bee3f8;
498
+ border-radius: 6px; font-size: .85rem; color: #2a4365;
499
+ }
500
+ .deliverable-run-hint code {
501
+ background: #e2e8f0; padding: 1px 6px; border-radius: 4px; font-size: .82rem;
502
+ }
503
+ .deliverable-doc-hint {
504
+ margin-top: 8px; padding: 8px 12px; background: #f0fff4; border: 1px solid #c6f6d5;
505
+ border-radius: 6px; font-size: .85rem; color: #22543d;
506
+ }
507
+ .deliverable-doc-hint code, .deliverable-image-hint code {
508
+ background: #e2e8f0; padding: 1px 6px; border-radius: 4px; font-size: .82rem;
509
+ }
510
+ .deliverable-image-hint {
511
+ margin-top: 8px; padding: 8px 12px; background: #faf5ff; border: 1px solid #e9d8fd;
512
+ border-radius: 6px; font-size: .85rem; color: #44337a;
513
+ }
514
+
515
+ .footer {
516
+ text-align: center; font-size: .8rem; color: var(--muted);
517
+ padding: 16px 0; margin-top: 8px;
518
+ }
519
+
520
+ @media (max-width: 600px) {
521
+ body { padding: 12px 8px; }
522
+ .header { padding: 20px 16px; }
523
+ .header h1 { font-size: 1.2rem; }
524
+ .preview-iframe { height: 280px; }
525
+ }
526
+ </style>
527
+ </head>
528
+ <body>
529
+ <div class="header">
530
+ <h1>${statusEmoji} ${escapeHtml(report.projectName)}</h1>
531
+ <div class="header-meta">
532
+ <span>Status: <strong>${statusLabel}</strong></span>
533
+ <span>⏱️ ${formatDuration(report.totalDurationMs)}</span>
534
+ <span>📋 ${report.taskCount} task${report.taskCount !== 1 ? "s" : ""}</span>
535
+ <span>👥 ${report.rolesUsed.join(", ") || "—"}</span>
536
+ </div>
537
+ <div class="requirement">${escapeHtml(report.requirementSummary)}</div>
538
+ </div>
539
+
540
+ <section class="section">
541
+ <h2>📋 Task Pipeline</h2>
542
+ <div class="pipeline">
543
+ ${phasesHtml}
544
+ </div>
545
+ </section>
546
+
547
+ ${report.deliverables.length > 0 ? `
548
+ <section class="section">
549
+ <h2>📦 Deliverables</h2>
550
+ ${deliverablesHtml}
551
+ </section>` : ""}
552
+
553
+ ${keyPointsHtml}
554
+ ${blockersHtml}
555
+ ${followUpsHtml}
556
+ ${notesHtml}
557
+
558
+ <div class="footer">
559
+ Generated by TeamClaw · ${new Date(report.generatedAt).toLocaleString()}
560
+ </div>
561
+ </body>
562
+ </html>`;
563
+ }