@desplega.ai/agent-swarm 1.92.0 → 1.92.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 (80) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +276 -3
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +327 -20
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  11. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  12. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  13. package/src/be/migrations/087_skill_files.sql +19 -0
  14. package/src/be/modelsdev-cache.json +264 -328
  15. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  16. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  17. package/src/be/seed-scripts/catalog/compound-insights.ts +94 -0
  18. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  19. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  20. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
  21. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  22. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  23. package/src/be/seed-scripts/index.ts +32 -4
  24. package/src/be/seed-skills/index.ts +0 -7
  25. package/src/be/skill-sync.ts +91 -7
  26. package/src/commands/runner.ts +6 -2
  27. package/src/heartbeat/templates.ts +20 -16
  28. package/src/http/index.ts +41 -7
  29. package/src/http/mcp-user.ts +23 -0
  30. package/src/http/mcp.ts +58 -0
  31. package/src/http/memory.ts +58 -0
  32. package/src/http/pages.ts +1 -1
  33. package/src/http/script-runs.ts +2 -0
  34. package/src/http/scripts.ts +39 -2
  35. package/src/http/skills.ts +225 -0
  36. package/src/providers/claude-adapter.ts +56 -24
  37. package/src/script-workflows/workflow-ctx.ts +7 -3
  38. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  39. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  40. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  41. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  42. package/src/server.ts +2 -0
  43. package/src/tests/claude-adapter-binary.test.ts +135 -81
  44. package/src/tests/create-page-tool.test.ts +19 -2
  45. package/src/tests/heartbeat-checklist.test.ts +36 -0
  46. package/src/tests/mcp-transport-gc.test.ts +58 -0
  47. package/src/tests/memory-health-endpoint.test.ts +78 -0
  48. package/src/tests/memory-store.test.ts +221 -1
  49. package/src/tests/pages-http.test.ts +20 -2
  50. package/src/tests/pages-storage.test.ts +26 -0
  51. package/src/tests/scripts-mcp-e2e.test.ts +53 -0
  52. package/src/tests/seed-scripts.test.ts +123 -3
  53. package/src/tests/skill-files-http.test.ts +171 -0
  54. package/src/tests/skill-files.test.ts +162 -0
  55. package/src/tests/skill-get-file-tool.test.ts +110 -0
  56. package/src/tests/skill-sync.test.ts +125 -6
  57. package/src/tools/create-page.ts +2 -2
  58. package/src/tools/skills/index.ts +1 -0
  59. package/src/tools/skills/skill-get-file.ts +80 -0
  60. package/src/tools/tool-config.ts +2 -1
  61. package/src/types.ts +20 -0
  62. package/src/utils/internal-ai/complete-structured.ts +2 -2
  63. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  64. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  65. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  66. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  67. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  68. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  69. package/templates/skills/agentmail-sending/content.md +6 -7
  70. package/templates/skills/desloppify/content.md +8 -9
  71. package/templates/skills/jira-interaction/content.md +25 -33
  72. package/templates/skills/kapso-whatsapp/content.md +29 -30
  73. package/templates/skills/linear-interaction/content.md +8 -9
  74. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  75. package/templates/skills/sprite-cli/content.md +4 -5
  76. package/templates/skills/turso-interaction/content.md +14 -17
  77. package/templates/skills/workflow-iterate/content.md +38 -391
  78. package/templates/skills/x-api-interactions/content.md +4 -6
  79. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  80. package/templates/skills/scheduled-task-resilience/content.md +0 -95
@@ -0,0 +1,221 @@
1
+ import { z } from "zod";
2
+
3
+ export const argsSchema = z.object({
4
+ nowIso: z.string().optional().describe("Triage clock override (default: current time)"),
5
+ failureLookbackMinutes: z
6
+ .number()
7
+ .int()
8
+ .positive()
9
+ .optional()
10
+ .describe("Look back this many minutes for real failures (default 60)"),
11
+ stuckMinutes: z
12
+ .number()
13
+ .int()
14
+ .positive()
15
+ .optional()
16
+ .describe("Flag in-progress tasks older than this on offline agents (default 5)"),
17
+ deployWindowMinutes: z
18
+ .number()
19
+ .int()
20
+ .positive()
21
+ .optional()
22
+ .describe("Window around now for merged agent-swarm PRs (default 15)"),
23
+ repo: z
24
+ .string()
25
+ .optional()
26
+ .describe("Optional GitHub repository in 'owner/name' form for restart/deploy PR detection"),
27
+ });
28
+
29
+ const BENIGN_FAILURE_RE = /^(superseded_workflow_task|cancelled|reboot-sweep)$/i;
30
+
31
+ function rowsToObjects(res: any): any[] {
32
+ const p = res?.data ?? res;
33
+ const cols: string[] = p?.columns ?? [];
34
+ return (p?.rows ?? []).map((r: any) =>
35
+ Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
36
+ );
37
+ }
38
+
39
+ async function query(ctx: any, sql: string, params?: unknown[]): Promise<any[]> {
40
+ try {
41
+ return rowsToObjects(await ctx.swarm.db_query({ sql, params }));
42
+ } catch (error) {
43
+ return [{ unavailable: error instanceof Error ? error.message : String(error) }];
44
+ }
45
+ }
46
+
47
+ function taskPreview(task: unknown): string {
48
+ return String(task || "").replace(/\s+/g, " ").trim().slice(0, 180);
49
+ }
50
+
51
+ function summarizeTask(row: any): any {
52
+ return {
53
+ id: row.id,
54
+ status: row.status,
55
+ taskType: row.taskType || null,
56
+ agentId: row.agentId || null,
57
+ agentName: row.agentName || null,
58
+ scheduleId: row.scheduleId || null,
59
+ parentTaskId: row.parentTaskId || null,
60
+ failureReason: row.failureReason || null,
61
+ createdAt: row.createdAt,
62
+ lastUpdatedAt: row.lastUpdatedAt,
63
+ taskPreview: taskPreview(row.task),
64
+ };
65
+ }
66
+
67
+ async function recentMergedPrs(
68
+ ctx: any,
69
+ repo: string | undefined,
70
+ nowMs: number,
71
+ windowMinutes: number,
72
+ ): Promise<any> {
73
+ if (!repo) return { skipped: "repo not provided" };
74
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return { error: "repo must be in 'owner/name' form" };
75
+
76
+ const windowMs = windowMinutes * 60 * 1000;
77
+ try {
78
+ const response = await ctx.stdlib.fetch(
79
+ "https://api.github.com/repos/" +
80
+ repo +
81
+ "/pulls?state=closed&sort=updated&direction=desc&per_page=20",
82
+ {
83
+ headers: {
84
+ Accept: "application/vnd.github+json",
85
+ "User-Agent": "agent-swarm-boot-triage",
86
+ },
87
+ },
88
+ );
89
+ if (!response.ok) return { error: `GitHub API ${response.status}` };
90
+ const prs = (await response.json()) as any[];
91
+ return prs
92
+ .filter((pr) => pr?.merged_at)
93
+ .map((pr) => {
94
+ const mergedAtMs = Date.parse(pr.merged_at);
95
+ return {
96
+ repo,
97
+ number: pr.number,
98
+ title: pr.title,
99
+ url: pr.html_url,
100
+ mergedAt: pr.merged_at,
101
+ minutesFromRestart: Math.round((mergedAtMs - nowMs) / 60000),
102
+ };
103
+ })
104
+ .filter((pr) => Math.abs(pr.minutesFromRestart * 60000) <= windowMs);
105
+ } catch (error) {
106
+ return { error: error instanceof Error ? error.message : String(error) };
107
+ }
108
+ }
109
+
110
+ /** Read-only post-restart triage snapshot for the heartbeat.boot-triage prompt. */
111
+ export default async function bootTriage(args: any, ctx: any) {
112
+ const parsed = argsSchema.safeParse(args || {});
113
+ if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
114
+
115
+ const now = parsed.data.nowIso ? new Date(parsed.data.nowIso) : new Date();
116
+ const nowMs = now.getTime();
117
+ const failureLookbackMinutes = parsed.data.failureLookbackMinutes || 60;
118
+ const stuckMinutes = parsed.data.stuckMinutes || 5;
119
+ const deployWindowMinutes = parsed.data.deployWindowMinutes || 15;
120
+ const repo = parsed.data.repo;
121
+
122
+ const mergedPrs = await recentMergedPrs(ctx, repo, nowMs, deployWindowMinutes);
123
+
124
+ const recentFailureRows = await query(
125
+ ctx,
126
+ `SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
127
+ t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
128
+ FROM agent_tasks t
129
+ LEFT JOIN agents a ON a.id = t.agentId
130
+ WHERE t.status = 'failed'
131
+ AND datetime(t.lastUpdatedAt) >= datetime(?, ?)
132
+ ORDER BY datetime(t.lastUpdatedAt) DESC
133
+ LIMIT 50`,
134
+ [now.toISOString(), `-${failureLookbackMinutes} minutes`],
135
+ );
136
+ const recentlyFailedTasks = recentFailureRows
137
+ .filter((row) => !row.unavailable)
138
+ .filter((row) => !BENIGN_FAILURE_RE.test(String(row.failureReason || "")))
139
+ .map(summarizeTask);
140
+
141
+ const stuckOfflineRows = await query(
142
+ ctx,
143
+ `SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
144
+ t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
145
+ FROM agent_tasks t
146
+ JOIN agents a ON a.id = t.agentId
147
+ WHERE t.status = 'in_progress'
148
+ AND a.status = 'offline'
149
+ AND datetime(t.lastUpdatedAt) <= datetime(?, ?)
150
+ ORDER BY datetime(t.lastUpdatedAt) ASC
151
+ LIMIT 50`,
152
+ [now.toISOString(), `-${stuckMinutes} minutes`],
153
+ );
154
+ const stuckInProgressOnOfflineAgents = stuckOfflineRows
155
+ .filter((row) => !row.unavailable)
156
+ .map(summarizeTask);
157
+
158
+ const orphanRows = await query(
159
+ ctx,
160
+ `SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
161
+ t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
162
+ FROM agent_tasks t
163
+ JOIN agents a ON a.id = t.agentId
164
+ WHERE t.status IN ('pending', 'offered')
165
+ AND a.status = 'offline'
166
+ ORDER BY datetime(t.lastUpdatedAt) ASC
167
+ LIMIT 50`,
168
+ );
169
+ const orphanedPendingOrOfferedOnOfflineWorkers = orphanRows
170
+ .filter((row) => !row.unavailable)
171
+ .map(summarizeTask);
172
+
173
+ const supersededRows = await query(
174
+ ctx,
175
+ `SELECT p.id, p.task, p.status, p.taskType, p.agentId, a.name as agentName,
176
+ p.scheduleId, p.parentTaskId, p.failureReason, p.createdAt, p.lastUpdatedAt
177
+ FROM agent_tasks p
178
+ LEFT JOIN agents a ON a.id = p.agentId
179
+ WHERE p.status = 'superseded'
180
+ AND datetime(p.lastUpdatedAt) >= datetime(?, ?)
181
+ AND NOT EXISTS (
182
+ SELECT 1
183
+ FROM agent_tasks c
184
+ WHERE c.parentTaskId = p.id
185
+ AND c.taskType = 'resume'
186
+ AND c.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
187
+ )
188
+ ORDER BY datetime(p.lastUpdatedAt) DESC
189
+ LIMIT 50`,
190
+ [now.toISOString(), `-${failureLookbackMinutes} minutes`],
191
+ );
192
+ const supersededTasksMissingResumeChild = supersededRows
193
+ .filter((row) => !row.unavailable)
194
+ .map(summarizeTask);
195
+
196
+ return {
197
+ generatedAt: now.toISOString(),
198
+ windows: {
199
+ failureLookbackMinutes,
200
+ stuckMinutes,
201
+ deployWindowMinutes,
202
+ },
203
+ deployRestartDetection: {
204
+ source: repo ? "github:" + repo : null,
205
+ mergedPrsWithinWindow: Array.isArray(mergedPrs) ? mergedPrs : [],
206
+ skipped: Array.isArray(mergedPrs) ? null : mergedPrs.skipped || null,
207
+ error: Array.isArray(mergedPrs) ? null : mergedPrs.error,
208
+ },
209
+ recentlyFailedTasks,
210
+ stuckInProgressOnOfflineAgents,
211
+ orphanedPendingOrOfferedOnOfflineWorkers,
212
+ supersededTasksMissingResumeChild,
213
+ summary: {
214
+ mergedPrsWithinWindow: Array.isArray(mergedPrs) ? mergedPrs.length : 0,
215
+ recentlyFailedTasks: recentlyFailedTasks.length,
216
+ stuckInProgressOnOfflineAgents: stuckInProgressOnOfflineAgents.length,
217
+ orphanedPendingOrOfferedOnOfflineWorkers: orphanedPendingOrOfferedOnOfflineWorkers.length,
218
+ supersededTasksMissingResumeChild: supersededTasksMissingResumeChild.length,
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,457 @@
1
+ export type CatalogReportFinding = {
2
+ id: string;
3
+ severity?: string;
4
+ summary: string;
5
+ action?: string;
6
+ samples?: unknown[];
7
+ };
8
+
9
+ export type CatalogReportSection = {
10
+ key: string;
11
+ label?: string;
12
+ goal: string;
13
+ findingCount?: number;
14
+ checks?: Record<string, unknown>;
15
+ findings?: CatalogReportFinding[];
16
+ };
17
+
18
+ export type CatalogReport = {
19
+ title: string;
20
+ slug: string;
21
+ description: string;
22
+ generatedAt: string;
23
+ lede: string;
24
+ metrics: Array<[string, unknown]>;
25
+ sections: CatalogReportSection[];
26
+ appendix: unknown;
27
+ };
28
+
29
+ function catalogReportAsText(value: unknown): string {
30
+ return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value);
31
+ }
32
+
33
+ function catalogReportHtmlEscape(value: unknown): string {
34
+ return catalogReportAsText(value)
35
+ .replace(/&/g, "&amp;")
36
+ .replace(/</g, "&lt;")
37
+ .replace(/>/g, "&gt;")
38
+ .replace(/"/g, "&quot;");
39
+ }
40
+
41
+ function catalogReportHumanLabel(value: string): string {
42
+ return value
43
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
44
+ .replace(/[-_.]+/g, " ")
45
+ .replace(/\b\w/g, (char) => char.toUpperCase());
46
+ }
47
+
48
+ function catalogReportFormatMetric(value: unknown): string {
49
+ if (typeof value === "number") return new Intl.NumberFormat("en-US").format(value);
50
+ return catalogReportAsText(value);
51
+ }
52
+
53
+ function catalogReportSeverityTone(value?: string): string {
54
+ if (value === "critical") return "danger";
55
+ if (value === "high") return "warn";
56
+ if (value === "medium") return "note";
57
+ return "low";
58
+ }
59
+
60
+ function catalogReportRenderSampleValue(value: unknown): string {
61
+ if (Array.isArray(value)) {
62
+ return value
63
+ .map((item) => (typeof item === "object" && item !== null ? JSON.stringify(item) : catalogReportAsText(item)))
64
+ .join(", ");
65
+ }
66
+ if (value && typeof value === "object") return JSON.stringify(value);
67
+ return catalogReportAsText(value);
68
+ }
69
+
70
+ function catalogReportRenderSamples(samples?: unknown[]): string {
71
+ if (!Array.isArray(samples) || samples.length === 0) return "";
72
+ const normalized = samples.map((sampleRow) =>
73
+ sampleRow && typeof sampleRow === "object" && !Array.isArray(sampleRow)
74
+ ? (sampleRow as Record<string, unknown>)
75
+ : { value: sampleRow },
76
+ );
77
+ const columns = Array.from(
78
+ new Set(normalized.flatMap((sampleRow) => Object.keys(sampleRow).slice(0, 6))),
79
+ ).slice(0, 6);
80
+ if (columns.length === 0) return "";
81
+ const rows = normalized
82
+ .map(
83
+ (sampleRow) =>
84
+ `<tr>${columns
85
+ .map((column) => `<td>${catalogReportHtmlEscape(catalogReportRenderSampleValue(sampleRow[column]))}</td>`)
86
+ .join("")}</tr>`,
87
+ )
88
+ .join("");
89
+ return `<div class="sample-table" aria-label="Sample rows">
90
+ <table>
91
+ <thead><tr>${columns.map((column) => `<th>${catalogReportHtmlEscape(catalogReportHumanLabel(column))}</th>`).join("")}</tr></thead>
92
+ <tbody>${rows}</tbody>
93
+ </table>
94
+ </div>`;
95
+ }
96
+
97
+ export function renderCatalogReportPage(report: CatalogReport): string {
98
+ const sections = report.sections
99
+ .map((section) => {
100
+ const findings = (section.findings || [])
101
+ .map(
102
+ (finding) => `<article class="finding ${catalogReportHtmlEscape(catalogReportSeverityTone(finding.severity))}">
103
+ <div class="finding-head">
104
+ <div>
105
+ <p class="finding-id">${catalogReportHtmlEscape(finding.id)}</p>
106
+ <h3>${catalogReportHtmlEscape(finding.summary)}</h3>
107
+ </div>
108
+ <span class="pill ${catalogReportHtmlEscape(catalogReportSeverityTone(finding.severity))}">${catalogReportHtmlEscape(
109
+ finding.severity || "low",
110
+ )}</span>
111
+ </div>
112
+ ${finding.action ? `<p class="action">${catalogReportHtmlEscape(finding.action)}</p>` : ""}
113
+ ${catalogReportRenderSamples(finding.samples)}
114
+ </article>`,
115
+ )
116
+ .join("");
117
+ const checks = Object.entries(section.checks || {})
118
+ .map(
119
+ ([label, value]) =>
120
+ `<div class="check"><span>${catalogReportHtmlEscape(catalogReportHumanLabel(label))}</span><strong>${catalogReportHtmlEscape(
121
+ catalogReportFormatMetric(value),
122
+ )}</strong></div>`,
123
+ )
124
+ .join("");
125
+ return `<section class="section">
126
+ <div class="section-grid">
127
+ <aside class="checks">
128
+ <p class="section-kicker">${catalogReportHtmlEscape(section.label || catalogReportHumanLabel(section.key))}</p>
129
+ <div class="check-list">${checks}</div>
130
+ </aside>
131
+ <div>
132
+ <div class="section-head">
133
+ <h2>${catalogReportHtmlEscape(section.goal)}</h2>
134
+ <span>${catalogReportHtmlEscape(catalogReportFormatMetric(section.findingCount ?? section.findings?.length ?? 0))} finding(s)</span>
135
+ </div>
136
+ <div class="findings">
137
+ ${findings || '<p class="empty">No actionable findings in this cluster.</p>'}
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </section>`;
142
+ })
143
+ .join("\n");
144
+ return `<!doctype html>
145
+ <html lang="en">
146
+ <head>
147
+ <meta charset="utf-8">
148
+ <meta name="viewport" content="width=device-width, initial-scale=1">
149
+ <meta name="theme-color" content="#f5f2ea">
150
+ <title>${catalogReportHtmlEscape(report.title)}</title>
151
+ <style>
152
+ :root {
153
+ color-scheme: light;
154
+ --bg: #f5f2ea;
155
+ --panel: #ffffff;
156
+ --ink: #18181b;
157
+ --muted: #5f6368;
158
+ --line: #ded8cb;
159
+ --accent: #255c99;
160
+ --danger: #b42318;
161
+ --danger-bg: #fff1f0;
162
+ --warn: #b54708;
163
+ --warn-bg: #fff7ed;
164
+ --note: #175cd3;
165
+ --note-bg: #eff6ff;
166
+ --low: #067647;
167
+ --low-bg: #ecfdf3;
168
+ --radius: 8px;
169
+ --shadow: 0 1px 2px rgba(24, 24, 27, 0.06), 0 14px 36px rgba(24, 24, 27, 0.07);
170
+ }
171
+ * { box-sizing: border-box; }
172
+ body {
173
+ margin: 0;
174
+ background: var(--bg);
175
+ color: var(--ink);
176
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
177
+ font-size: 16px;
178
+ line-height: 1.55;
179
+ }
180
+ main {
181
+ width: min(1120px, calc(100% - 32px));
182
+ margin: 0 auto;
183
+ padding: 48px 0 72px;
184
+ }
185
+ header { margin-bottom: 28px; }
186
+ .eyebrow {
187
+ margin: 0 0 8px;
188
+ color: var(--muted);
189
+ font-size: 13px;
190
+ font-weight: 750;
191
+ letter-spacing: 0.08em;
192
+ text-transform: uppercase;
193
+ }
194
+ h1 {
195
+ margin: 0;
196
+ max-width: 860px;
197
+ font-size: clamp(2rem, 4vw, 3rem);
198
+ line-height: 1.05;
199
+ letter-spacing: 0;
200
+ }
201
+ .lede {
202
+ max-width: 780px;
203
+ margin: 16px 0 0;
204
+ color: var(--muted);
205
+ font-size: 18px;
206
+ }
207
+ .metrics {
208
+ display: grid;
209
+ grid-template-columns: repeat(4, minmax(0, 1fr));
210
+ gap: 12px;
211
+ margin: 32px 0;
212
+ }
213
+ .metric, .section, details {
214
+ background: var(--panel);
215
+ border: 1px solid var(--line);
216
+ border-radius: var(--radius);
217
+ box-shadow: var(--shadow);
218
+ }
219
+ .metric { padding: 18px; }
220
+ .metric strong {
221
+ display: block;
222
+ font-size: 32px;
223
+ line-height: 1;
224
+ font-variant-numeric: tabular-nums;
225
+ }
226
+ .metric span {
227
+ display: block;
228
+ margin-top: 8px;
229
+ color: var(--muted);
230
+ font-size: 13px;
231
+ font-weight: 650;
232
+ }
233
+ .section {
234
+ margin-top: 18px;
235
+ padding: 24px;
236
+ }
237
+ .section-grid {
238
+ display: grid;
239
+ grid-template-columns: 260px minmax(0, 1fr);
240
+ gap: 28px;
241
+ }
242
+ .section-kicker {
243
+ margin: 0 0 12px;
244
+ color: var(--accent);
245
+ font-size: 13px;
246
+ font-weight: 800;
247
+ letter-spacing: 0.08em;
248
+ text-transform: uppercase;
249
+ }
250
+ .check-list {
251
+ display: grid;
252
+ gap: 8px;
253
+ }
254
+ .check {
255
+ display: flex;
256
+ align-items: baseline;
257
+ justify-content: space-between;
258
+ gap: 12px;
259
+ padding: 10px 0;
260
+ border-bottom: 1px solid var(--line);
261
+ }
262
+ .check span {
263
+ color: var(--muted);
264
+ font-size: 13px;
265
+ }
266
+ .check strong {
267
+ font-size: 18px;
268
+ font-variant-numeric: tabular-nums;
269
+ }
270
+ .section-head {
271
+ display: flex;
272
+ align-items: start;
273
+ justify-content: space-between;
274
+ gap: 16px;
275
+ margin-bottom: 16px;
276
+ }
277
+ .section-head h2 {
278
+ max-width: 680px;
279
+ margin: 0;
280
+ font-size: 24px;
281
+ line-height: 1.2;
282
+ letter-spacing: 0;
283
+ }
284
+ .section-head > span {
285
+ flex: 0 0 auto;
286
+ color: var(--muted);
287
+ font-size: 13px;
288
+ font-weight: 700;
289
+ white-space: nowrap;
290
+ }
291
+ .findings {
292
+ display: grid;
293
+ gap: 12px;
294
+ }
295
+ .finding {
296
+ border: 1px solid var(--line);
297
+ border-left: 4px solid var(--note);
298
+ border-radius: var(--radius);
299
+ padding: 16px;
300
+ background: #fffdf8;
301
+ }
302
+ .finding.danger { border-left-color: var(--danger); }
303
+ .finding.warn { border-left-color: var(--warn); }
304
+ .finding.low { border-left-color: var(--low); }
305
+ .finding-head {
306
+ display: flex;
307
+ align-items: start;
308
+ justify-content: space-between;
309
+ gap: 16px;
310
+ }
311
+ .finding-id {
312
+ margin: 0 0 4px;
313
+ color: var(--muted);
314
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
315
+ font-size: 12px;
316
+ }
317
+ h3 {
318
+ margin: 0;
319
+ font-size: 17px;
320
+ line-height: 1.3;
321
+ letter-spacing: 0;
322
+ }
323
+ .pill {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ min-height: 26px;
327
+ padding: 4px 9px;
328
+ border-radius: 999px;
329
+ font-size: 12px;
330
+ font-weight: 800;
331
+ text-transform: uppercase;
332
+ white-space: nowrap;
333
+ }
334
+ .pill.danger { background: var(--danger-bg); color: var(--danger); }
335
+ .pill.warn { background: var(--warn-bg); color: var(--warn); }
336
+ .pill.note { background: var(--note-bg); color: var(--note); }
337
+ .pill.low { background: var(--low-bg); color: var(--low); }
338
+ .action {
339
+ margin: 10px 0 0;
340
+ color: var(--muted);
341
+ }
342
+ .sample-table {
343
+ margin-top: 14px;
344
+ overflow-x: auto;
345
+ border: 1px solid var(--line);
346
+ border-radius: var(--radius);
347
+ background: var(--panel);
348
+ }
349
+ table {
350
+ width: 100%;
351
+ min-width: 640px;
352
+ border-collapse: collapse;
353
+ }
354
+ th, td {
355
+ padding: 10px 12px;
356
+ border-bottom: 1px solid var(--line);
357
+ text-align: left;
358
+ vertical-align: top;
359
+ }
360
+ th {
361
+ color: var(--muted);
362
+ font-size: 12px;
363
+ font-weight: 800;
364
+ letter-spacing: 0.06em;
365
+ text-transform: uppercase;
366
+ }
367
+ td {
368
+ max-width: 360px;
369
+ color: #27272a;
370
+ font-size: 13px;
371
+ overflow-wrap: anywhere;
372
+ }
373
+ tr:last-child td { border-bottom: 0; }
374
+ .empty {
375
+ margin: 0;
376
+ color: var(--muted);
377
+ }
378
+ details {
379
+ margin-top: 24px;
380
+ padding: 18px;
381
+ }
382
+ summary {
383
+ cursor: pointer;
384
+ font-weight: 800;
385
+ }
386
+ pre {
387
+ margin: 16px 0 0;
388
+ max-height: 560px;
389
+ overflow: auto;
390
+ padding: 16px;
391
+ border-radius: var(--radius);
392
+ background: #111827;
393
+ color: #f9fafb;
394
+ font-size: 12px;
395
+ line-height: 1.45;
396
+ }
397
+ @media (max-width: 860px) {
398
+ main { width: min(100% - 24px, 1120px); padding-top: 32px; }
399
+ .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
400
+ .section-grid { grid-template-columns: 1fr; gap: 18px; }
401
+ .section { padding: 18px; }
402
+ .section-head { display: block; }
403
+ .section-head > span { display: block; margin-top: 8px; }
404
+ }
405
+ @media (max-width: 520px) {
406
+ .metrics { grid-template-columns: 1fr; }
407
+ .lede { font-size: 16px; }
408
+ .finding-head { display: block; }
409
+ .pill { margin-top: 10px; }
410
+ }
411
+ </style>
412
+ </head>
413
+ <body>
414
+ <main>
415
+ <header>
416
+ <p class="eyebrow">Generated ${catalogReportHtmlEscape(report.generatedAt)}</p>
417
+ <h1>${catalogReportHtmlEscape(report.title)}</h1>
418
+ <p class="lede">${catalogReportHtmlEscape(report.lede)}</p>
419
+ </header>
420
+ <section class="metrics" aria-label="Audit summary">
421
+ ${report.metrics
422
+ .map(
423
+ ([label, value]) =>
424
+ `<div class="metric"><strong>${catalogReportHtmlEscape(catalogReportFormatMetric(value))}</strong><span>${catalogReportHtmlEscape(
425
+ label,
426
+ )}</span></div>`,
427
+ )
428
+ .join("")}
429
+ </section>
430
+ ${sections}
431
+ <details>
432
+ <summary>Compressed JSON appendix</summary>
433
+ <pre>${catalogReportHtmlEscape(JSON.stringify(report.appendix, null, 2))}</pre>
434
+ </details>
435
+ </main>
436
+ </body>
437
+ </html>`;
438
+ }
439
+
440
+ export async function publishCatalogReportPage(report: CatalogReport, ctx: any): Promise<any> {
441
+ const response = await ctx.swarm.page_create({
442
+ title: report.title,
443
+ slug: report.slug,
444
+ description: report.description,
445
+ contentType: "text/html",
446
+ authMode: "authed",
447
+ body: renderCatalogReportPage(report),
448
+ });
449
+ const payload = response?.data ?? response;
450
+ if (payload?.success === false) return { error: payload.error || "page_create failed" };
451
+ return {
452
+ id: payload?.id ?? payload?.page?.id ?? null,
453
+ appUrl: payload?.appUrl ?? payload?.app_url ?? null,
454
+ apiUrl: payload?.apiUrl ?? payload?.api_url ?? null,
455
+ version: payload?.version ?? payload?.page?.version ?? null,
456
+ };
457
+ }