loadout-ai 0.4.0 → 0.5.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 (48) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/MASTER_PLAN.md +173 -9
  3. package/README.md +57 -19
  4. package/catalog/discovered.json +8422 -6957
  5. package/catalog/packages.json +26 -0
  6. package/dist/src/cli.js +140 -68
  7. package/dist/src/core/active-policy.js +216 -42
  8. package/dist/src/core/active-set.js +14 -5
  9. package/dist/src/core/adopt.js +1 -1
  10. package/dist/src/core/catalog-coverage.js +2 -1
  11. package/dist/src/core/catalog-install.js +23 -8
  12. package/dist/src/core/cli-guide.js +5 -2
  13. package/dist/src/core/codex-mcp.js +49 -5
  14. package/dist/src/core/completion.js +0 -3
  15. package/dist/src/core/health.js +19 -11
  16. package/dist/src/core/install.js +6 -39
  17. package/dist/src/core/mcp-recipes.js +13 -4
  18. package/dist/src/core/mcp.js +6 -5
  19. package/dist/src/core/model-config.js +1 -1
  20. package/dist/src/core/portable.js +1 -1
  21. package/dist/src/core/recommend.js +129 -27
  22. package/dist/src/core/remove.js +28 -2
  23. package/dist/src/core/runtime-tool-recipe.js +2 -2
  24. package/dist/src/core/runtime-tools.js +15 -4
  25. package/dist/src/core/snapshot.js +20 -0
  26. package/dist/src/core/state.js +9 -0
  27. package/dist/src/core/sync.js +1 -1
  28. package/dist/src/core/target-occupancy.js +50 -0
  29. package/dist/src/core/transaction.js +2 -1
  30. package/dist/src/shared/schemas.js +1 -0
  31. package/docs/CATALOG.md +3 -2
  32. package/docs/DISCOVERED.md +253 -255
  33. package/docs/FEATURE_TEST_MATRIX.md +19 -32
  34. package/docs/GITHUB_AUTHORIZATION.md +2 -2
  35. package/docs/TESTING.md +3 -16
  36. package/docs/USER_TEST_GUIDE.md +39 -13
  37. package/docs/assets/loadout-workflow.png +0 -0
  38. package/docs/evidence/readme-claims.json +0 -8
  39. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
  40. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
  41. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
  42. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
  43. package/package.json +4 -6
  44. package/dashboard/app.js +0 -607
  45. package/dashboard/index.html +0 -249
  46. package/dashboard/styles.css +0 -384
  47. package/dist/src/core/demo.js +0 -136
  48. package/dist/src/dashboard.js +0 -421
@@ -1146,5 +1146,31 @@
1146
1146
  "evidencePaths": ["server.js", "package.json"],
1147
1147
  "verifiedAt": "2026-07-16T08:45:18Z"
1148
1148
  }
1149
+ },
1150
+ {
1151
+ "id": "obsidian-skills",
1152
+ "displayName": "Obsidian Skills",
1153
+ "repository": "kepano/obsidian-skills",
1154
+ "description": "Agent skills for working with Obsidian Markdown, Bases, JSON Canvas, Defuddle, and the Obsidian CLI.",
1155
+ "category": "knowledge-management",
1156
+ "tier": "community",
1157
+ "license": "MIT",
1158
+ "components": ["skill", "plugin"],
1159
+ "operatingSystems": ["windows", "macos", "linux"],
1160
+ "stars": 42827,
1161
+ "pushedAt": "2026-06-08T16:12:01Z",
1162
+ "source": {
1163
+ "type": "github",
1164
+ "url": "https://github.com/kepano/obsidian-skills",
1165
+ "defaultBranch": "main",
1166
+ "commit": "a1dc48e68138490d522c04cbf5822214c6eb1202",
1167
+ "evidencePaths": [
1168
+ "skills/obsidian-markdown/SKILL.md",
1169
+ "skills/obsidian-bases/SKILL.md",
1170
+ "skills/json-canvas/SKILL.md",
1171
+ ".claude-plugin/plugin.json"
1172
+ ],
1173
+ "verifiedAt": "2026-07-21T08:45:00Z"
1174
+ }
1149
1175
  }
1150
1176
  ]
package/dist/src/cli.js CHANGED
@@ -3,21 +3,21 @@ import { Command, CommanderError } from "commander";
3
3
  import { existsSync } from "node:fs";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, rankCatalog, refreshCatalog, } from "./core/catalog.js";
6
- import { detectAgents, parseAgentSelection } from "./core/paths.js";
6
+ import { detectAgents, parseAgentSelection, userHome } from "./core/paths.js";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { dirname, join, resolve } from "node:path";
9
9
  import { buildSkillPlan, applySkillInstall, installedAgents, } from "./core/install.js";
10
- import { listSnapshotIds, readSnapshot, restoreSnapshot, } from "./core/snapshot.js";
10
+ import { listSnapshotIds, readSnapshot, restoreSnapshot, summarizeSnapshot, } from "./core/snapshot.js";
11
11
  import { fetchRepositorySnapshot } from "./core/source.js";
12
12
  import { discoverMcpManifests, summarizeMcpManifest, planMcpConfig, summarizeMcpConfigPlan, applyMcpConfigPlan, } from "./core/mcp.js";
13
- import { REVIEWED_MCP_RECIPES, findMcpRecipe, formatMcpRecipePlan, planMcpRecipe, verifyMcpRecipe, verifyMcpRecipeConnection, } from "./core/mcp-recipes.js";
13
+ import { REVIEWED_MCP_RECIPES, buildMcpRecipeServer, findMcpRecipe, formatMcpRecipePlan, planMcpRecipe, verifyMcpRecipe, verifyMcpRecipeConnection, } from "./core/mcp-recipes.js";
14
14
  import { runDoctor, formatDoctorReport } from "./core/doctor.js";
15
15
  import { applyPackageUpdate, buildUpdatePlan, formatUpdatePlan, selectSafeAutomaticUpdates, } from "./core/update.js";
16
16
  import { startApiServer } from "./core/api.js";
17
17
  import { inspectPackage, formatPackageInspection } from "./core/package.js";
18
18
  import { addManifestPackage, applyProfileToManifest, initManifest, readManifest, removeManifestPackage, writeLockfile, } from "./core/manifest.js";
19
19
  import { buildHealthReport, formatHealthReport } from "./core/health.js";
20
- import { readInstallState, recordInstallTransaction } from "./core/state.js";
20
+ import { installStatePath, readInstallState, recordInstallTransaction, recordMcpInstall, } from "./core/state.js";
21
21
  import { applyRemove, planRemove } from "./core/remove.js";
22
22
  import { applyUninstall, buildUninstallPlan, formatUninstallPlan, uninstallGlobalCli, } from "./core/uninstall.js";
23
23
  import { evaluateInstalledProfile, formatInstalledProfileStatus, } from "./core/profile-state.js";
@@ -31,8 +31,7 @@ import { ADAPTER_CAPABILITIES, buildAdapterCapabilityGaps, formatAdapterCapabili
31
31
  import { formatAgentInventory, inspectAgents, } from "./core/agent-inspection.js";
32
32
  import { generateSigningKeys, signJsonFile, verifyJsonFile, } from "./core/signing.js";
33
33
  import { applyPortableImport, exportPortableLoadout, planPortableImport, } from "./core/portable.js";
34
- import { applyCodexMcpConfigPlan, defaultCodexMcpConfigPath, planCodexMcpConfig, } from "./core/codex-mcp.js";
35
- import { formatDemoResult, runIsolatedDemo } from "./core/demo.js";
34
+ import { applyCodexMcpConfigPlan, codexMcpServerFingerprint, defaultCodexMcpConfigPath, planCodexMcpConfig, } from "./core/codex-mcp.js";
36
35
  import { catalogTrustStage, formatCatalogTrustStage, resolveCatalogProfile, } from "./core/profiles.js";
37
36
  import { discoverHackerNewsRepositories } from "./core/community.js";
38
37
  import { discoverPrivateRepositories } from "./core/private-discovery.js";
@@ -44,7 +43,6 @@ import { runDisposableSandbox } from "./core/sandbox.js";
44
43
  import { compileConversion, } from "./core/conversion.js";
45
44
  import { writeFileAtomically } from "./core/atomic-file.js";
46
45
  import { formatCanaryResult, runCanary } from "./core/canary.js";
47
- import { startDashboardServer } from "./dashboard.js";
48
46
  import { applyPreparedCatalogInstall, formatCatalogApplyGuidance, formatPreparedCatalogInstall, prepareCatalogInstall, } from "./core/catalog-install.js";
49
47
  import { formatInstalledSkillInventory, scanInstalledSkills, } from "./core/skill-inventory.js";
50
48
  import { enrichInventoryWithProvenance, formatProvenanceSummary, resolveCatalogSkillIndex, } from "./core/provenance.js";
@@ -222,7 +220,7 @@ async function runSetup(options) {
222
220
  onProgress: printSetupProgress,
223
221
  access,
224
222
  });
225
- console.log(`\n${formatPreparedCatalogInstall(prepared)}\n`);
223
+ console.log(`\n${formatPreparedCatalogInstall(prepared, { details: options.details })}\n`);
226
224
  const risky = riskyPackageSummary(prepared);
227
225
  let approved = Boolean(options.yes);
228
226
  let riskApproved = Boolean(options.approveRisk);
@@ -265,7 +263,7 @@ async function runSetup(options) {
265
263
  reader?.close();
266
264
  }
267
265
  }
268
- const LOADOUT_VERSION = "0.4.0";
266
+ const LOADOUT_VERSION = "0.5.0";
269
267
  function durableSchedulerLauncher() {
270
268
  return [
271
269
  join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
@@ -308,6 +306,7 @@ program
308
306
  .option("--api-access <providers>", "separately billed model API access: none, openai, anthropic, openrouter, or other (comma-separated; never a key)")
309
307
  .option("-y, --yes", "install after preparing the screened plan")
310
308
  .option("--approve-risk", "approve reviewed safety findings in non-interactive mode")
309
+ .option("--details", "show every quarantined and deferred unit")
311
310
  .action((options) => runSetup(options));
312
311
  program
313
312
  .command("upgrade")
@@ -1077,6 +1076,12 @@ program
1077
1076
  : Object.entries(TESTED_PROFILES)
1078
1077
  .map(([id, profile]) => `${id} — ${profile.description}`)
1079
1078
  .join("\n"));
1079
+ if (name === "custom" && !options.applyTo) {
1080
+ const profile = TESTED_PROFILES.custom;
1081
+ return console.log(options.json
1082
+ ? JSON.stringify({ name, ...profile, packages: [] }, null, 2)
1083
+ : `custom: ${profile.description}\n Use: loadout setup --mode custom --package <id>`);
1084
+ }
1080
1085
  const packages = profileManifestPackages(name, await loadEffectiveCatalog());
1081
1086
  if (options.applyTo) {
1082
1087
  const manifest = await applyProfileToManifest(options.applyTo, name, packages);
@@ -1465,26 +1470,6 @@ benchmark
1465
1470
  if (!summary.withinBudget)
1466
1471
  process.exitCode = 2;
1467
1472
  });
1468
- program
1469
- .command("demo")
1470
- .alias("test-drive")
1471
- .description("Test-drive a reviewed install + rollback in a disposable profile; never touches local agent config")
1472
- .option("--repository <owner/repo>", "public GitHub skill repository", "obra/superpowers")
1473
- .option("--package <id>", "package identifier", "obra-superpowers")
1474
- .option("--agents <ids>", "comma-separated virtual demo targets", "codex")
1475
- .option("--keep", "retain the isolated profile after install for inspection")
1476
- .option("--json", "emit the demo result as JSON")
1477
- .action(async (options) => {
1478
- const result = await runIsolatedDemo({
1479
- repository: options.repository,
1480
- packageId: options.package,
1481
- agents: parseAgentSelection(options.agents),
1482
- keep: options.keep,
1483
- });
1484
- console.log(options.json
1485
- ? JSON.stringify(result, null, 2)
1486
- : formatDemoResult(result));
1487
- });
1488
1473
  program
1489
1474
  .command("capabilities")
1490
1475
  .description("Show honest native, adapted, and unsupported adapter capabilities")
@@ -2150,6 +2135,7 @@ program
2150
2135
  .command("mcp-recipe")
2151
2136
  .description("Preview/configure a reviewed MCP recipe, or explicitly verify its real connection")
2152
2137
  .argument("[id]", "recipe id; omit to list reviewed recipes")
2138
+ .option("--agent <id>", "target host: codex or claude-code; selects its default config format and path")
2153
2139
  .option("--config <path>", "target JSON MCP config path")
2154
2140
  .option("--yes", "write the reviewed server entry after preview")
2155
2141
  .option("--verify", "verify the configured entry without starting a server")
@@ -2176,11 +2162,14 @@ program
2176
2162
  environment: recipe.environment,
2177
2163
  modelApiProviders: recipe.modelApiProviders,
2178
2164
  }));
2179
- console.log(options.json
2165
+ const output = options.json
2180
2166
  ? JSON.stringify(listed, null, 2)
2181
2167
  : listed
2182
2168
  .map((recipe) => `${recipe.id} — ${recipe.displayName} — ${recipe.modelApiProviders.length ? `AI API required: ${recipe.modelApiProviders.join(", ")}` : "No AI API key required"} · ${recipe.environment.length ? `${recipe.environment.includes("GITHUB_PERSONAL_ACCESS_TOKEN") ? "GitHub token required" : `service credential required: ${recipe.environment.join(", ")}`}` : "no other credential"}`)
2183
- .join("\n"));
2169
+ .join("\n");
2170
+ console.log(output);
2171
+ if (!options.json)
2172
+ console.log("\nInstall for one host (preview first):\n loadout mcp-recipe playwright --agent codex\n loadout mcp-recipe playwright --agent claude-code");
2184
2173
  return;
2185
2174
  }
2186
2175
  if (options.connect) {
@@ -2210,12 +2199,41 @@ program
2210
2199
  }
2211
2200
  return;
2212
2201
  }
2213
- if (!options.config)
2214
- throw new Error("--config is required for recipe planning or --verify");
2202
+ if (options.agent &&
2203
+ options.agent !== "codex" &&
2204
+ options.agent !== "claude-code")
2205
+ throw new Error("--agent must be codex or claude-code");
2206
+ if (options.agent && options.config)
2207
+ throw new Error("Choose --agent for a managed default path or --config for an explicit JSON path, not both");
2208
+ const configPath = options.config ??
2209
+ (options.agent === "codex"
2210
+ ? defaultCodexMcpConfigPath()
2211
+ : options.agent === "claude-code"
2212
+ ? join(userHome(), ".claude.json")
2213
+ : undefined);
2214
+ if (!configPath)
2215
+ throw new Error("Choose --agent codex, --agent claude-code, or an explicit --config path");
2215
2216
  if (options.verify) {
2216
2217
  if (options.yes)
2217
2218
  throw new Error("--verify cannot be combined with --yes");
2218
- const verification = await verifyMcpRecipe(id, options.config);
2219
+ if (options.agent === "codex") {
2220
+ const recipe = findMcpRecipe(id);
2221
+ let content = "";
2222
+ try {
2223
+ content = await readFile(configPath, "utf8");
2224
+ }
2225
+ catch {
2226
+ // Missing config is rendered as a normal not-configured result.
2227
+ }
2228
+ const configured = Boolean(codexMcpServerFingerprint(content, recipe.serverName));
2229
+ console.log(options.json
2230
+ ? JSON.stringify({ recipeId: id, configPath, configured }, null, 2)
2231
+ : `${configured ? "Configured" : "Not configured"}: ${id}\nCodex config: ${configPath}\nConfiguration presence does not launch the MCP server; add --connect --approve-risk for a bounded handshake.`);
2232
+ if (!configured)
2233
+ process.exitCode = 1;
2234
+ return;
2235
+ }
2236
+ const verification = await verifyMcpRecipe(id, configPath);
2219
2237
  console.log(options.json
2220
2238
  ? JSON.stringify(verification, null, 2)
2221
2239
  : `${verification.configured ? "Configured" : "Not configured"}: ${verification.recipeId}\n${[...verification.checks, ...verification.warnings].join("\n")}`);
@@ -2225,21 +2243,82 @@ program
2225
2243
  }
2226
2244
  const recipe = findMcpRecipe(id);
2227
2245
  const credentialReferences = parseMcpCredentialMappings(recipe, options.credential, options.credentialAccount);
2228
- const plan = await planMcpRecipe(id, options.config, {
2246
+ if (options.agent === "codex") {
2247
+ const server = buildMcpRecipeServer(id, configPath, {
2248
+ credentialReferences,
2249
+ requireResolvedCredentials: Boolean(options.yes),
2250
+ });
2251
+ const plan = await planCodexMcpConfig(configPath, server, server.name);
2252
+ const recipe = findMcpRecipe(id);
2253
+ const preview = [
2254
+ `MCP recipe: ${recipe.displayName}`,
2255
+ `Target: Codex`,
2256
+ `Config: ${plan.path}`,
2257
+ `Change: ${plan.summary}`,
2258
+ `Artifact: ${recipe.artifact}`,
2259
+ `Permissions: ${recipe.permissions.join(", ")}`,
2260
+ recipe.environment.length
2261
+ ? `Credential references required: ${recipe.environment.join(", ")}`
2262
+ : "Credentials: none",
2263
+ ].join("\n");
2264
+ if (!options.yes) {
2265
+ console.log(options.json
2266
+ ? JSON.stringify({
2267
+ recipe,
2268
+ agent: "codex",
2269
+ path: plan.path,
2270
+ summary: plan.summary,
2271
+ }, null, 2)
2272
+ : `${preview}\nDry run only. Re-run with --yes to add this reviewed Codex MCP server.`);
2273
+ return;
2274
+ }
2275
+ const fingerprint = codexMcpServerFingerprint(plan.proposed, recipe.serverName);
2276
+ if (!fingerprint)
2277
+ throw new Error("Could not fingerprint the applied Codex MCP entry");
2278
+ const snapshot = await applyCodexMcpConfigPlan(plan, {
2279
+ extraTargets: [installStatePath()],
2280
+ afterApply: async (_result, transactionSnapshot) => recordMcpInstall({
2281
+ packageId: `mcp-recipe:${id}:codex`,
2282
+ configPath: plan.path,
2283
+ serverName: recipe.serverName,
2284
+ fingerprint,
2285
+ snapshotId: transactionSnapshot.id,
2286
+ installedAt: new Date().toISOString(),
2287
+ configFormat: "codex-toml",
2288
+ }),
2289
+ });
2290
+ console.log(options.json
2291
+ ? JSON.stringify({
2292
+ recipe,
2293
+ agent: "codex",
2294
+ snapshotId: snapshot.rollbackSnapshotId,
2295
+ }, null, 2)
2296
+ : `${preview}\nConfigured for Codex. Managed package: mcp-recipe:${id}:codex\nRollback: loadout rollback --snapshot ${snapshot.rollbackSnapshotId}\nNext: loadout mcp-recipe ${id} --agent codex --verify`);
2297
+ return;
2298
+ }
2299
+ const plan = await planMcpRecipe(id, configPath, {
2229
2300
  credentialReferences,
2230
2301
  requireResolvedCredentials: Boolean(options.yes),
2231
2302
  });
2232
2303
  if (!options.yes) {
2304
+ const target = options.agent === "claude-code" ? "Target: Claude Code\n" : "";
2233
2305
  console.log(options.json
2234
- ? JSON.stringify(plan, null, 2)
2235
- : `${formatMcpRecipePlan(plan)}\nDry run only. Re-run with --yes to write this server entry.`);
2306
+ ? JSON.stringify(options.agent ? { agent: options.agent, ...plan } : plan, null, 2)
2307
+ : `${target}${formatMcpRecipePlan(plan)}\nDry run only. Re-run with --yes to write this server entry.`);
2236
2308
  return;
2237
2309
  }
2238
- const snapshot = await applyMcpConfigPlan(plan.config);
2239
- await recordInstallTransaction([], [{ packageId: `mcp-recipe:${id}`, plan: plan.config }], snapshot.id);
2310
+ const managedId = options.agent
2311
+ ? `mcp-recipe:${id}:${options.agent}`
2312
+ : `mcp-recipe:${id}`;
2313
+ const snapshot = await applyMcpConfigPlan(plan.config, {
2314
+ extraTargets: [installStatePath()],
2315
+ afterApply: async (_result, transactionSnapshot) => {
2316
+ await recordInstallTransaction([], [{ packageId: managedId, plan: plan.config }], transactionSnapshot.id);
2317
+ },
2318
+ });
2240
2319
  console.log(options.json
2241
2320
  ? JSON.stringify({ plan, snapshot }, null, 2)
2242
- : `${formatMcpRecipePlan(plan)}\nConfigured. Snapshot: ${snapshot.id}\nAuthorize the service separately, then run: loadout mcp-recipe ${id} --config ${plan.config.path} --verify`);
2321
+ : `${options.agent === "claude-code" ? "Target: Claude Code\n" : ""}${formatMcpRecipePlan(plan)}\nConfigured${options.agent ? " for Claude Code" : ""}. Managed package: ${managedId}\nRollback: loadout rollback --snapshot ${snapshot.rollbackSnapshotId}\nAuthorize the service separately, then run: loadout mcp-recipe ${id} ${options.agent ? `--agent ${options.agent}` : `--config ${plan.config.path}`} --verify`);
2243
2322
  });
2244
2323
  program
2245
2324
  .command("mcp")
@@ -2439,7 +2518,7 @@ program
2439
2518
  return;
2440
2519
  }
2441
2520
  const snapshot = await applyMcpConfigPlan(plan);
2442
- console.log(`Applied successfully. Snapshot: ${snapshot.id}`);
2521
+ console.log(`Applied successfully. Snapshot: ${snapshot.rollbackSnapshotId}`);
2443
2522
  });
2444
2523
  program
2445
2524
  .command("codex-mcp-config")
@@ -2478,7 +2557,7 @@ program
2478
2557
  if (!options.yes)
2479
2558
  return console.log("Dry run only. Re-run with --yes to add this Codex MCP server.");
2480
2559
  const snapshot = await applyCodexMcpConfigPlan(plan);
2481
- console.log(`Applied successfully. Snapshot: ${snapshot.id}`);
2560
+ console.log(`Applied successfully. Snapshot: ${snapshot.rollbackSnapshotId}`);
2482
2561
  });
2483
2562
  program
2484
2563
  .command("plan")
@@ -2575,6 +2654,7 @@ program
2575
2654
  .option("--agents <ids>", "comma-separated agent ids; defaults to all detected agents")
2576
2655
  .option("--yes", "apply without interactive confirmation")
2577
2656
  .option("--approve-risk", "approve reviewed safety findings for catalog mode")
2657
+ .option("--details", "show every quarantined and deferred unit")
2578
2658
  .action(async (options) => {
2579
2659
  const packageIds = options.package ?? [];
2580
2660
  const hasSource = Boolean(options.source || options.repository);
@@ -2589,7 +2669,9 @@ program
2589
2669
  requestedAgents: parseAgentSelection(options.agents),
2590
2670
  onProgress: printSetupProgress,
2591
2671
  });
2592
- console.log(formatPreparedCatalogInstall(prepared));
2672
+ console.log(formatPreparedCatalogInstall(prepared, {
2673
+ details: options.details,
2674
+ }));
2593
2675
  if (!options.yes) {
2594
2676
  console.log(formatCatalogApplyGuidance(prepared.entries.some((entry) => entry.safety.approvalRequired)));
2595
2677
  return;
@@ -2662,8 +2744,15 @@ program
2662
2744
  const snapshotIds = await listSnapshotIds();
2663
2745
  if (!snapshotIds.length)
2664
2746
  return console.log("No Loadout snapshots found.");
2665
- for (const id of snapshotIds)
2666
- console.log(id);
2747
+ console.log("Loadout rollback history (oldest to newest):");
2748
+ for (const [index, id] of snapshotIds.entries()) {
2749
+ const summary = summarizeSnapshot(await readSnapshot(id));
2750
+ const impact = summary.changedEntries === undefined
2751
+ ? "legacy impact unknown"
2752
+ : `${summary.changedEntries} changed filesystem entr${summary.changedEntries === 1 ? "y" : "ies"}`;
2753
+ console.log(`${index === snapshotIds.length - 1 ? "latest" : " "} ${summary.createdAt} ${summary.label} ${impact} ${summary.roots} root(s) ${id}`);
2754
+ }
2755
+ console.log("Restore an exact entry with: loadout rollback --snapshot <id>");
2667
2756
  return;
2668
2757
  }
2669
2758
  const selected = await withMutationLock(async () => {
@@ -2671,12 +2760,15 @@ program
2671
2760
  const chosen = options.snapshot ?? snapshotIds.at(-1);
2672
2761
  if (!chosen)
2673
2762
  throw new Error("No Loadout snapshots found");
2674
- await restoreSnapshot(await readSnapshot(chosen), {
2763
+ const snapshot = await readSnapshot(chosen);
2764
+ await restoreSnapshot(snapshot, {
2675
2765
  requireUnchangedPostMutationState: true,
2676
2766
  });
2677
- return chosen;
2767
+ return summarizeSnapshot(snapshot);
2678
2768
  });
2679
- console.log(`Restored snapshot ${selected}`);
2769
+ console.log(`Restored ${selected.label} snapshot ${selected.id} (${selected.changedEntries ?? "unknown"} recorded changed filesystem entries across ${selected.roots} root(s)).`);
2770
+ if (selected.changedEntries === 0)
2771
+ console.log("This snapshot recorded no effective filesystem change. Choose an older explicit snapshot if you meant to undo an earlier installation.");
2680
2772
  });
2681
2773
  program
2682
2774
  .command("update")
@@ -2844,26 +2936,6 @@ program
2844
2936
  if (result.status === "blocked")
2845
2937
  process.exitCode = 1;
2846
2938
  });
2847
- program
2848
- .command("dashboard")
2849
- .description("Start the local Loadout dashboard on a loopback-only port")
2850
- .option("--port <port>", "TCP port (0 selects an available port)", "0")
2851
- .action(async (options) => {
2852
- const port = Number(options.port);
2853
- if (!Number.isInteger(port) || port < 0 || port > 65_535)
2854
- throw new Error("--port must be an integer between 0 and 65535");
2855
- const handle = await startDashboardServer({}, port);
2856
- console.log(`Loadout dashboard: http://${handle.host}:${handle.port}`);
2857
- await new Promise((resolve) => {
2858
- const stop = () => {
2859
- process.off("SIGINT", stop);
2860
- process.off("SIGTERM", stop);
2861
- void handle.close().then(resolve);
2862
- };
2863
- process.on("SIGINT", stop);
2864
- process.on("SIGTERM", stop);
2865
- });
2866
- });
2867
2939
  program
2868
2940
  .command("serve")
2869
2941
  .description("Start a loopback-only read-only API for status, health, catalog, and updates")