loadout-ai 0.1.0

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,607 @@
1
+ const status = document.querySelector("#status");
2
+ const catalog = document.querySelector("#catalog");
3
+ const count = document.querySelector("#catalog-count");
4
+ const updates = document.querySelector("#updates-list");
5
+ const updatesCount = document.querySelector("#updates-count");
6
+ const health = document.querySelector("#health");
7
+ const healthLabel = document.querySelector("#health-label");
8
+ const recommendations = document.querySelector("#recommendations");
9
+ const projectSignals = document.querySelector("#project-signals");
10
+ const profiles = document.querySelector("#profiles");
11
+ const registry = document.querySelector("#registry");
12
+ const registryCount = document.querySelector("#registry-count");
13
+ const installed = document.querySelector("#installed-list");
14
+ const installedCount = document.querySelector("#installed-count");
15
+ const lastSnapshot = document.querySelector("#last-snapshot");
16
+ const catalogSearch = document.querySelector("#catalog-search");
17
+ const catalogTier = document.querySelector("#catalog-tier");
18
+ const catalogCategory = document.querySelector("#catalog-category");
19
+ const refreshDashboard = document.querySelector("#refresh-dashboard");
20
+ const previewSync = document.querySelector("#preview-sync");
21
+ const applySync = document.querySelector("#apply-sync");
22
+ const rollbackSync = document.querySelector("#rollback-sync");
23
+ const syncAcknowledgement = document.querySelector("#sync-acknowledgement");
24
+ const syncResult = document.querySelector("#sync-result");
25
+ const notice = document.querySelector("#dashboard-notice");
26
+
27
+ let sessionToken;
28
+ let latestSnapshot;
29
+ let reviewedSafePlan = false;
30
+ let renderRun = 0;
31
+ let latestCatalog = [];
32
+
33
+ const routeNames = new Set(["home", "discover", "installed", "updates"]);
34
+ const routeLinks = [...document.querySelectorAll("[data-route]")];
35
+ const screens = [...document.querySelectorAll("[data-screen]")];
36
+
37
+ function selectedRoute() {
38
+ const route = window.location.hash.slice(1);
39
+ return routeNames.has(route) ? route : "home";
40
+ }
41
+
42
+ function setRoute() {
43
+ const route = selectedRoute();
44
+ for (const screen of screens) {
45
+ const screenRoute = screen.id || screen.dataset.screen;
46
+ screen.hidden = screenRoute !== route;
47
+ }
48
+ for (const link of routeLinks) {
49
+ if (link.dataset.route === route) link.setAttribute("aria-current", "page");
50
+ else link.removeAttribute("aria-current");
51
+ }
52
+ }
53
+
54
+ function errorMessage(error) {
55
+ return error instanceof Error && error.message
56
+ ? error.message
57
+ : "An unexpected local error occurred";
58
+ }
59
+
60
+ async function responseBody(response) {
61
+ const contentType = response.headers.get("content-type") || "";
62
+ if (!contentType.includes("application/json")) {
63
+ throw new Error(`Expected a local JSON response (${response.status})`);
64
+ }
65
+ try {
66
+ return await response.json();
67
+ } catch {
68
+ throw new Error(
69
+ `The local dashboard returned invalid JSON (${response.status})`,
70
+ );
71
+ }
72
+ }
73
+
74
+ async function load(path) {
75
+ const response = await fetch(path, {
76
+ headers: { accept: "application/json" },
77
+ });
78
+ const body = await responseBody(response);
79
+ if (!response.ok)
80
+ throw new Error(
81
+ typeof body.error === "string"
82
+ ? body.error
83
+ : `Request failed (${response.status})`,
84
+ );
85
+ return body;
86
+ }
87
+
88
+ async function mutate(path, value) {
89
+ if (!sessionToken) sessionToken = (await load("/api/session")).token;
90
+ if (typeof sessionToken !== "string" || !sessionToken)
91
+ throw new Error("Could not establish a private dashboard session");
92
+ const response = await fetch(path, {
93
+ method: "POST",
94
+ headers: {
95
+ accept: "application/json",
96
+ "content-type": "application/json",
97
+ "x-loadout-token": sessionToken,
98
+ },
99
+ body: JSON.stringify(value),
100
+ });
101
+ const result = await responseBody(response);
102
+ if (!response.ok)
103
+ throw new Error(
104
+ typeof result.error === "string"
105
+ ? result.error
106
+ : `Request failed (${response.status})`,
107
+ );
108
+ return result;
109
+ }
110
+
111
+ function escapeHtml(value) {
112
+ return String(value).replace(
113
+ /[&<>"']/g,
114
+ (char) =>
115
+ ({
116
+ "&": "&amp;",
117
+ "<": "&lt;",
118
+ ">": "&gt;",
119
+ '"': "&quot;",
120
+ "'": "&#039;",
121
+ })[char],
122
+ );
123
+ }
124
+
125
+ function asArray(value) {
126
+ return Array.isArray(value) ? value : [];
127
+ }
128
+
129
+ function announce(message) {
130
+ notice.textContent = message;
131
+ }
132
+
133
+ function setBusy(element, message) {
134
+ element.className = "state";
135
+ element.setAttribute("aria-busy", "true");
136
+ element.setAttribute("role", "status");
137
+ element.textContent = message;
138
+ }
139
+
140
+ function setTextState(element, message, kind = "default") {
141
+ element.className = `state${kind === "default" ? "" : ` ${kind}`}`;
142
+ element.setAttribute("aria-busy", "false");
143
+ element.setAttribute("role", kind === "error" ? "alert" : "status");
144
+ element.textContent = message;
145
+ }
146
+
147
+ function setMarkupState(element, className, markup) {
148
+ element.className = className;
149
+ element.setAttribute("aria-busy", "false");
150
+ element.setAttribute("role", "status");
151
+ element.innerHTML = markup;
152
+ }
153
+
154
+ function renderUpdate(plan, index) {
155
+ const label =
156
+ plan.status === "update-available"
157
+ ? "Update available"
158
+ : plan.status === "up-to-date"
159
+ ? "Up to date"
160
+ : plan.status === "untracked"
161
+ ? "Needs tracking"
162
+ : "Could not check";
163
+ const details = plan.diff?.length
164
+ ? `<details><summary>${plan.diff.length} changed file${plan.diff.length === 1 ? "" : "s"}</summary><ul>${plan.diff
165
+ .slice(0, 20)
166
+ .map(
167
+ (change) =>
168
+ `<li><code>${escapeHtml(change.path)}</code> <small>${escapeHtml(change.kind)}</small></li>`,
169
+ )
170
+ .join("")}</ul></details>`
171
+ : "";
172
+ const safety = asArray(plan.safetyFindings).length
173
+ ? `<details class="safety"><summary>${plan.approvalRequired ? "Approval required" : "Safety notes"}</summary><ul>${asArray(
174
+ plan.safetyFindings,
175
+ )
176
+ .map(
177
+ (finding) =>
178
+ `<li><strong>${escapeHtml(finding.category || "review")}</strong>: ${escapeHtml(finding.message || "Review this change.")}${asArray(finding.names).length ? ` <small>${asArray(finding.names).map(escapeHtml).join(", ")}</small>` : ""}</li>`,
179
+ )
180
+ .join("")}</ul></details>`
181
+ : "";
182
+ const headingId = `update-${index}`;
183
+ const commits =
184
+ plan.installedCommit || plan.availableCommit
185
+ ? `<small>Installed ${escapeHtml(String(plan.installedCommit || "unknown").slice(0, 12))} → candidate ${escapeHtml(String(plan.availableCommit || "unknown").slice(0, 12))}</small>`
186
+ : "";
187
+ return `<article class="update ${escapeHtml(plan.status || "unknown")}" aria-labelledby="${headingId}"><div class="update-heading"><h3 id="${headingId}">${escapeHtml(plan.packageId || "Unknown package")}</h3><span class="badge">${label}</span></div><p>${escapeHtml(plan.action || "No update action available.")}</p>${plan.repository ? `<small>${escapeHtml(plan.repository)}</small>` : ""}${commits}${plan.error ? `<p class="error-text">${escapeHtml(plan.error)}</p>` : ""}${safety}${details}</article>`;
188
+ }
189
+
190
+ function describeSync(plan) {
191
+ const packages = asArray(plan.packages);
192
+ const mcpChanges = asArray(plan.mcpChanges);
193
+ const policyViolations = asArray(plan.policyViolations);
194
+ const files = packages.reduce(
195
+ (total, pkg) => total + asArray(pkg.files).length,
196
+ 0,
197
+ );
198
+ const risky = packages
199
+ .filter((pkg) => pkg.safety?.approvalRequired)
200
+ .map((pkg) => pkg.packageId)
201
+ .filter(Boolean);
202
+ return {
203
+ text: `${packages.length} package(s), ${files} file target(s), ${mcpChanges.length} MCP plan(s)${risky.length ? `; CLI risk approval required for ${risky.join(", ")}` : ""}`,
204
+ safe: !risky.length && !policyViolations.length,
205
+ };
206
+ }
207
+
208
+ function resetSyncReview() {
209
+ reviewedSafePlan = false;
210
+ syncAcknowledgement.checked = false;
211
+ syncAcknowledgement.disabled = true;
212
+ applySync.disabled = true;
213
+ }
214
+
215
+ syncAcknowledgement.addEventListener("change", () => {
216
+ applySync.disabled = !reviewedSafePlan || !syncAcknowledgement.checked;
217
+ });
218
+
219
+ previewSync.addEventListener("click", async () => {
220
+ previewSync.disabled = true;
221
+ resetSyncReview();
222
+ setTextState(syncResult, "Building a read-only plan…");
223
+ try {
224
+ const { plan } = await load("/api/sync-plan");
225
+ const summary = describeSync(plan || {});
226
+ setTextState(
227
+ syncResult,
228
+ summary.text,
229
+ summary.safe ? "success" : "attention",
230
+ );
231
+ reviewedSafePlan = summary.safe;
232
+ syncAcknowledgement.disabled = !summary.safe;
233
+ if (summary.safe)
234
+ announce(
235
+ "A safe sync plan is ready for review. Check the acknowledgement box to enable apply.",
236
+ );
237
+ else
238
+ announce(
239
+ "This plan needs CLI risk approval and cannot be applied from the dashboard.",
240
+ );
241
+ } catch (error) {
242
+ setTextState(
243
+ syncResult,
244
+ `Could not build sync plan: ${errorMessage(error)}`,
245
+ "error",
246
+ );
247
+ announce("Could not build the sync plan.");
248
+ } finally {
249
+ previewSync.disabled = false;
250
+ }
251
+ });
252
+
253
+ applySync.addEventListener("click", async () => {
254
+ if (!reviewedSafePlan || !syncAcknowledgement.checked) return;
255
+ applySync.disabled = true;
256
+ syncAcknowledgement.disabled = true;
257
+ setTextState(syncResult, "Applying the reviewed safe plan…");
258
+ try {
259
+ const { result } = await mutate("/api/sync", { approveRisk: false });
260
+ latestSnapshot = result?.snapshotId;
261
+ setTextState(
262
+ syncResult,
263
+ `Synchronized successfully.${latestSnapshot ? ` Snapshot: ${latestSnapshot}` : ""}`,
264
+ "success",
265
+ );
266
+ rollbackSync.disabled = !latestSnapshot;
267
+ resetSyncReview();
268
+ announce("Safe sync completed.");
269
+ await render();
270
+ } catch (error) {
271
+ setTextState(
272
+ syncResult,
273
+ `Synchronization failed: ${errorMessage(error)}`,
274
+ "error",
275
+ );
276
+ reviewedSafePlan = true;
277
+ syncAcknowledgement.disabled = false;
278
+ applySync.disabled = !syncAcknowledgement.checked;
279
+ announce("Synchronization failed.");
280
+ }
281
+ });
282
+
283
+ rollbackSync.addEventListener("click", async () => {
284
+ if (!latestSnapshot) return;
285
+ rollbackSync.disabled = true;
286
+ setTextState(syncResult, "Restoring the exact previous snapshot…");
287
+ try {
288
+ await mutate("/api/rollback", { snapshotId: latestSnapshot });
289
+ setTextState(syncResult, `Restored snapshot ${latestSnapshot}.`, "success");
290
+ latestSnapshot = undefined;
291
+ announce("The last dashboard change was restored.");
292
+ await render();
293
+ } catch (error) {
294
+ setTextState(
295
+ syncResult,
296
+ `Rollback failed: ${errorMessage(error)}`,
297
+ "error",
298
+ );
299
+ rollbackSync.disabled = false;
300
+ announce("Rollback failed.");
301
+ }
302
+ });
303
+
304
+ function renderStatus(data) {
305
+ const detected = asArray(data.agents).filter((agent) => agent?.installed);
306
+ if (!detected.length)
307
+ return setTextState(
308
+ status,
309
+ "No supported agent detected on PATH. Install one, then refresh.",
310
+ );
311
+ setMarkupState(
312
+ status,
313
+ "state success",
314
+ `<strong>${detected.length} agent${detected.length === 1 ? "" : "s"} detected</strong><span>${detected.map((agent) => escapeHtml(agent.displayName)).join(" · ")}</span>`,
315
+ );
316
+ }
317
+
318
+ function renderHealth(data) {
319
+ const report = data.health;
320
+ if (!report || typeof report !== "object")
321
+ throw new Error("The local health response was incomplete");
322
+ healthLabel.textContent =
323
+ typeof report.status === "string" ? report.status : "unknown";
324
+ const state =
325
+ report.status === "healthy"
326
+ ? "success"
327
+ : report.status === "unhealthy"
328
+ ? "error"
329
+ : "attention";
330
+ const findings = asArray(report.findings);
331
+ setMarkupState(
332
+ health,
333
+ `health-list ${state}`,
334
+ `<div class="metric-row"><strong>${Number(report.installedPackages) || 0}</strong><span>packages</span><strong>${report.updatesChecked ? Number(report.updatesAvailable) || 0 : "—"}</strong><span>${report.updatesChecked ? "updates" : "updates not checked"}</span><strong>${Number(report.driftedFiles) || 0}</strong><span>drifted files</span><strong>${Number(report.driftedMcpServers) || 0}</strong><span>drifted MCP</span></div>${findings.map((finding) => `<p><span class="finding-level">${escapeHtml(finding.level || "info")}</span>${escapeHtml(finding.message || "")}</p>`).join("")}`,
335
+ );
336
+ }
337
+
338
+ function renderUpdates(data) {
339
+ const plans = asArray(data.updates || data.data || data);
340
+ const available = plans.filter(
341
+ (plan) => plan?.status === "update-available",
342
+ ).length;
343
+ updatesCount.textContent = plans.length
344
+ ? `${available} available · ${plans.length} tracked`
345
+ : "";
346
+ if (!plans.length)
347
+ return setTextState(
348
+ updates,
349
+ "No tracked installations yet. Install a package with Loadout to start update tracking.",
350
+ );
351
+ setMarkupState(updates, "updates-grid", plans.map(renderUpdate).join(""));
352
+ }
353
+
354
+ function renderRecommendations(data) {
355
+ const signals = data.signals || {};
356
+ projectSignals.textContent =
357
+ [...asArray(signals.languages), ...asArray(signals.frameworks)].join(
358
+ " · ",
359
+ ) || "No known signals";
360
+ const items = asArray(data.recommendations);
361
+ if (!items.length)
362
+ return setTextState(recommendations, "No matching recommendations yet.");
363
+ setMarkupState(
364
+ recommendations,
365
+ "grid",
366
+ items
367
+ .map((item, index) => {
368
+ const headingId = `recommendation-${index}`;
369
+ return `<article aria-labelledby="${headingId}"><div class="card-heading"><h3 id="${headingId}">${escapeHtml(item.packageId || "Unknown package")}</h3><span class="badge">${escapeHtml(item.confidence || "unknown")}</span></div><p>${escapeHtml(item.reason || "No recommendation reason available.")}</p></article>`;
370
+ })
371
+ .join(""),
372
+ );
373
+ }
374
+
375
+ function renderProfiles(data) {
376
+ const entries =
377
+ data.profiles && typeof data.profiles === "object"
378
+ ? Object.entries(data.profiles)
379
+ : [];
380
+ if (!entries.length)
381
+ return setTextState(profiles, "No tested profiles are available.");
382
+ setMarkupState(
383
+ profiles,
384
+ "grid",
385
+ entries
386
+ .map(([name, profile], index) => {
387
+ const value = profile && typeof profile === "object" ? profile : {};
388
+ const headingId = `profile-${index}`;
389
+ return `<article aria-labelledby="${headingId}"><h3 id="${headingId}">${escapeHtml(name)}</h3><p>${escapeHtml(value.description || "No description available.")}</p><small>${asArray(value.packages).map(escapeHtml).join(" · ")}</small></article>`;
390
+ })
391
+ .join(""),
392
+ );
393
+ }
394
+
395
+ function renderCatalogOptions(packages) {
396
+ const previous = catalogCategory.value;
397
+ const categories = [
398
+ ...new Set(
399
+ packages
400
+ .map((pkg) => pkg?.category)
401
+ .filter((category) => typeof category === "string" && category),
402
+ ),
403
+ ].sort();
404
+ catalogCategory.innerHTML = `<option value="">All outcomes</option>${categories.map((category) => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}`;
405
+ if (categories.includes(previous)) catalogCategory.value = previous;
406
+ }
407
+
408
+ function filteredCatalog() {
409
+ const query = catalogSearch.value.trim().toLowerCase();
410
+ return latestCatalog.filter((pkg) => {
411
+ const searchable = [
412
+ pkg.displayName,
413
+ pkg.id,
414
+ pkg.category,
415
+ pkg.repository,
416
+ pkg.description,
417
+ ...asArray(pkg.topics),
418
+ ]
419
+ .join(" ")
420
+ .toLowerCase();
421
+ return (
422
+ (!query || searchable.includes(query)) &&
423
+ (!catalogTier.value || pkg.tier === catalogTier.value) &&
424
+ (!catalogCategory.value || pkg.category === catalogCategory.value)
425
+ );
426
+ });
427
+ }
428
+
429
+ function renderCatalogCards(packages) {
430
+ count.textContent = `${packages.length} package${packages.length === 1 ? "" : "s"}${packages.length !== latestCatalog.length ? ` of ${latestCatalog.length}` : ""}`;
431
+ if (!packages.length)
432
+ return setTextState(
433
+ catalog,
434
+ "The catalog is empty. Refresh it with loadout catalog --refresh.",
435
+ );
436
+ setMarkupState(
437
+ catalog,
438
+ "grid",
439
+ packages
440
+ .map((pkg, index) => {
441
+ const headingId = `catalog-${index}`;
442
+ const stars =
443
+ typeof pkg.stars === "number"
444
+ ? ` · ★${pkg.stars.toLocaleString()}`
445
+ : "";
446
+ const components =
447
+ asArray(pkg.components).map(escapeHtml).join(" · ") ||
448
+ "Not classified";
449
+ const platforms =
450
+ asArray(pkg.operatingSystems).map(escapeHtml).join(" · ") ||
451
+ "Source inspection supported on this platform";
452
+ const evidence = asArray(pkg.source?.evidencePaths)
453
+ .map((path) => `<li><code>${escapeHtml(path)}</code></li>`)
454
+ .join("");
455
+ return `<article aria-labelledby="${headingId}"><div class="card-heading"><h3 id="${headingId}">${escapeHtml(pkg.displayName || pkg.id || "Unknown package")}</h3><span class="badge ${escapeHtml(pkg.tier || "community")}">${escapeHtml(pkg.tier || "community")}</span></div><p>${escapeHtml(pkg.description || "No description available.")}</p><small>${escapeHtml(pkg.repository || "No repository")}${stars}${pkg.category ? ` · ${escapeHtml(pkg.category)}` : ""}</small><details><summary>Technical details</summary><dl><dt>License</dt><dd>${escapeHtml(pkg.license || "Not recorded")}</dd><dt>Components</dt><dd>${components}</dd><dt>Platforms</dt><dd>${platforms}</dd></dl>${evidence ? `<p class="muted">Reviewed evidence paths</p><ul>${evidence}</ul>` : ""}<p class="muted">Add with <code>loadout add ${escapeHtml(pkg.id || "<package>")} --catalog ${escapeHtml(pkg.id || "<package>")}</code>, then preview synchronization before applying.</p></details></article>`;
456
+ })
457
+ .join(""),
458
+ );
459
+ }
460
+
461
+ function renderCatalog(data) {
462
+ latestCatalog = asArray(data.packages);
463
+ renderCatalogOptions(latestCatalog);
464
+ renderCatalogCards(filteredCatalog());
465
+ }
466
+
467
+ function renderInstalled(data) {
468
+ const packages = asArray(data.packages);
469
+ installedCount.textContent = `${packages.length} package${packages.length === 1 ? "" : "s"}`;
470
+ lastSnapshot.textContent = data.lastKnownGoodSnapshot
471
+ ? `Last known-good snapshot: ${data.lastKnownGoodSnapshot}`
472
+ : "No Loadout restore point exists yet.";
473
+ if (!packages.length)
474
+ return setTextState(
475
+ installed,
476
+ "No Loadout-managed packages are installed. Use Discover to choose a package, add it to loadout.json, then preview synchronization.",
477
+ );
478
+ setMarkupState(
479
+ installed,
480
+ "grid",
481
+ packages
482
+ .map((pkg, index) => {
483
+ const headingId = `installed-${index}`;
484
+ const agents = asArray(pkg.targetAgents)
485
+ .map(
486
+ (agent) =>
487
+ `<li><span class="badge ${escapeHtml(agent.skillCompatibility || "unsupported")}">${escapeHtml(agent.skillCompatibility || "unsupported")}</span> ${escapeHtml(agent.displayName || agent.id)}${agent.detected ? "" : " <small>not detected on this machine</small>"}</li>`,
488
+ )
489
+ .join("");
490
+ return `<article aria-labelledby="${headingId}"><div class="card-heading"><h3 id="${headingId}">${escapeHtml(pkg.displayName || pkg.packageId || "Unknown package")}</h3><span class="badge">managed</span></div><p>${Number(pkg.fileCount) || 0} tracked file${Number(pkg.fileCount) === 1 ? "" : "s"} · installed ${escapeHtml(pkg.installedAt || "unknown")}</p><small>${escapeHtml(pkg.repository || "Local source")} · commit ${escapeHtml(String(pkg.resolvedCommit || "local").slice(0, 12))}</small><details><summary>Managed targets and recovery</summary><ul>${agents}</ul><p>Restore point: <code>${escapeHtml(pkg.snapshotId || "not recorded")}</code></p><p class="muted">Removal stays explicit: <code>loadout remove ${escapeHtml(pkg.packageId || "<package>")}</code> previews files before it deletes anything.</p></details></article>`;
491
+ })
492
+ .join(""),
493
+ );
494
+ }
495
+
496
+ function renderRegistry(data) {
497
+ const packages = asArray(data.packages);
498
+ registryCount.textContent = `${packages.length} local`;
499
+ if (!packages.length)
500
+ return setTextState(registry, "No local packages published yet.");
501
+ setMarkupState(
502
+ registry,
503
+ "grid",
504
+ packages
505
+ .map((pkg, index) => {
506
+ const headingId = `registry-${index}`;
507
+ return `<article aria-labelledby="${headingId}"><h3 id="${headingId}">${escapeHtml(pkg.name || "Unknown package")}@${escapeHtml(pkg.version || "unknown")}</h3><p>${escapeHtml(pkg.description || "No description available.")}</p></article>`;
508
+ })
509
+ .join(""),
510
+ );
511
+ }
512
+
513
+ function renderFailure(element, prefix, error) {
514
+ setTextState(element, `${prefix}: ${errorMessage(error)}`, "error");
515
+ }
516
+
517
+ async function render() {
518
+ const run = ++renderRun;
519
+ refreshDashboard.disabled = true;
520
+ refreshDashboard.textContent = "Refreshing…";
521
+ setBusy(status, "Loading agent status…");
522
+ setBusy(health, "Checking setup health…");
523
+ setBusy(updates, "Checking installed packages…");
524
+ setBusy(recommendations, "Reading local project signals…");
525
+ setBusy(profiles, "Loading profiles…");
526
+ setBusy(catalog, "Loading catalog…");
527
+ setBusy(registry, "Loading locally published packages…");
528
+ setBusy(installed, "Reading managed package state…");
529
+
530
+ const results = await Promise.allSettled([
531
+ load("/api/status"),
532
+ load("/api/health"),
533
+ load("/api/update"),
534
+ load("/api/recommendations"),
535
+ load("/api/profiles"),
536
+ load("/api/catalog"),
537
+ load("/api/registry"),
538
+ load("/api/installed"),
539
+ ]);
540
+ if (run !== renderRun) return;
541
+ const [
542
+ statusResult,
543
+ healthResult,
544
+ updatesResult,
545
+ recommendationsResult,
546
+ profilesResult,
547
+ catalogResult,
548
+ registryResult,
549
+ installedResult,
550
+ ] = results;
551
+ const applyResult = (result, element, prefix, draw) => {
552
+ if (result.status === "rejected")
553
+ return renderFailure(element, prefix, result.reason);
554
+ try {
555
+ draw(result.value);
556
+ } catch (error) {
557
+ renderFailure(element, prefix, error);
558
+ }
559
+ };
560
+ applyResult(statusResult, status, "Could not load status", renderStatus);
561
+ applyResult(healthResult, health, "Could not load health", renderHealth);
562
+ applyResult(updatesResult, updates, "Could not check updates", renderUpdates);
563
+ applyResult(
564
+ recommendationsResult,
565
+ recommendations,
566
+ "Could not build recommendations",
567
+ renderRecommendations,
568
+ );
569
+ applyResult(
570
+ profilesResult,
571
+ profiles,
572
+ "Could not load profiles",
573
+ renderProfiles,
574
+ );
575
+ applyResult(catalogResult, catalog, "Could not load catalog", renderCatalog);
576
+ applyResult(
577
+ registryResult,
578
+ registry,
579
+ "Could not load local registry",
580
+ renderRegistry,
581
+ );
582
+ applyResult(
583
+ installedResult,
584
+ installed,
585
+ "Could not load installed packages",
586
+ renderInstalled,
587
+ );
588
+ refreshDashboard.disabled = false;
589
+ refreshDashboard.textContent = "Refresh dashboard";
590
+ announce("Dashboard data refreshed.");
591
+ }
592
+
593
+ refreshDashboard.addEventListener("click", () => {
594
+ void render();
595
+ });
596
+ catalogSearch.addEventListener("input", () =>
597
+ renderCatalogCards(filteredCatalog()),
598
+ );
599
+ catalogTier.addEventListener("change", () =>
600
+ renderCatalogCards(filteredCatalog()),
601
+ );
602
+ catalogCategory.addEventListener("change", () =>
603
+ renderCatalogCards(filteredCatalog()),
604
+ );
605
+ window.addEventListener("hashchange", setRoute);
606
+ setRoute();
607
+ void render();