loadout-ai 0.2.3 → 0.4.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 (49) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/MASTER_PLAN.md +141 -33
  3. package/README.md +160 -255
  4. package/dashboard/app.js +4 -4
  5. package/dashboard/index.html +4 -4
  6. package/dist/src/cli.js +189 -32
  7. package/dist/src/core/active-set.js +37 -2
  8. package/dist/src/core/adapters.js +10 -0
  9. package/dist/src/core/adopt.js +165 -32
  10. package/dist/src/core/agent-health-score.js +2 -2
  11. package/dist/src/core/catalog-coverage.js +2 -1
  12. package/dist/src/core/catalog-install.js +8 -1
  13. package/dist/src/core/catalog-release.js +2 -1
  14. package/dist/src/core/cli-guide.js +101 -0
  15. package/dist/src/core/completion.js +3 -0
  16. package/dist/src/core/conformance.js +74 -0
  17. package/dist/src/core/install.js +36 -3
  18. package/dist/src/core/mcp-recipes.js +21 -0
  19. package/dist/src/core/profile-state.js +101 -0
  20. package/dist/src/core/profiles.js +9 -4
  21. package/dist/src/core/ranking.js +1 -1
  22. package/dist/src/core/readme-claims.js +10 -0
  23. package/dist/src/core/readme-facts.js +40 -0
  24. package/dist/src/core/recommend.js +9 -3
  25. package/dist/src/core/remove.js +6 -7
  26. package/dist/src/core/runtime-tools.js +10 -2
  27. package/dist/src/core/scheduler.js +4 -3
  28. package/dist/src/core/snapshot.js +58 -13
  29. package/dist/src/core/state.js +13 -3
  30. package/dist/src/core/transaction.js +2 -1
  31. package/dist/src/core/uninstall.js +133 -0
  32. package/dist/src/core/update.js +87 -58
  33. package/dist/src/dashboard.js +5 -2
  34. package/dist/src/shared/schemas.js +69 -0
  35. package/docs/FEATURE_TEST_MATRIX.md +16 -0
  36. package/docs/README_RESEARCH.md +36 -0
  37. package/docs/RELEASE_REVIEW.md +31 -5
  38. package/docs/REPOSITORY_STABILIZATION.md +190 -0
  39. package/docs/TESTING.md +75 -2
  40. package/docs/USER_TEST_GUIDE.md +202 -0
  41. package/docs/assets/loadout-hero.svg +259 -0
  42. package/docs/assets/loadout-mark.svg +54 -0
  43. package/docs/evidence/live-checks-2026-07-19.json +22 -0
  44. package/docs/evidence/live-checks.schema.json +28 -0
  45. package/docs/evidence/readme-claims.json +286 -0
  46. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
  47. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
  48. package/package.json +8 -4
  49. package/SIMPLE_PLAN.md +0 -44
@@ -0,0 +1,101 @@
1
+ import { resolveCatalogProfile } from "./profiles.js";
2
+ import { readInstallState, writeInstallState } from "./state.js";
3
+ export async function recordInstalledProfile(prepared) {
4
+ const profile = {
5
+ mode: prepared.selection.mode,
6
+ ...(prepared.selection.packageIds
7
+ ? { packageIds: [...prepared.selection.packageIds] }
8
+ : {}),
9
+ agents: prepared.agents.map((agent) => agent.id),
10
+ catalogPackages: prepared.resolution.packages
11
+ .filter((pkg) => pkg.components?.includes("skill"))
12
+ .map((pkg) => ({
13
+ packageId: pkg.id,
14
+ ...(pkg.source?.commit ? { reviewedCommit: pkg.source.commit } : {}),
15
+ })),
16
+ appliedAt: new Date().toISOString(),
17
+ };
18
+ const state = await readInstallState();
19
+ await writeInstallState({ ...state, profile });
20
+ return profile;
21
+ }
22
+ export function evaluateInstalledProfileState(state, catalog) {
23
+ const evaluatedAt = new Date().toISOString();
24
+ const boundary = "Checks the current signed/reviewed catalog. Newly discovered candidates stay recommendations until review evidence promotes them.";
25
+ if (!state.profile)
26
+ return {
27
+ installed: false,
28
+ expectedPackages: [],
29
+ missingPackages: [],
30
+ reviewedRevisionChanges: [],
31
+ needsRefresh: false,
32
+ evaluatedAt,
33
+ boundary,
34
+ };
35
+ const resolution = resolveCatalogProfile(catalog, {
36
+ mode: state.profile.mode,
37
+ ...(state.profile.packageIds
38
+ ? { packageIds: state.profile.packageIds }
39
+ : {}),
40
+ });
41
+ const expected = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
42
+ const installed = new Map(state.installs.map((record) => [record.packageId, record]));
43
+ const previousCatalog = new Map(state.profile.catalogPackages.map((record) => [record.packageId, record]));
44
+ const missingPackages = expected
45
+ .filter((pkg) => !installed.has(pkg.id))
46
+ .map((pkg) => pkg.id);
47
+ const reviewedRevisionChanges = expected.flatMap((pkg) => {
48
+ const previous = previousCatalog.get(pkg.id);
49
+ if (!previous ||
50
+ !pkg.source?.commit ||
51
+ previous.reviewedCommit?.toLowerCase() === pkg.source.commit.toLowerCase())
52
+ return [];
53
+ return [
54
+ {
55
+ packageId: pkg.id,
56
+ previousReviewedCommit: previous.reviewedCommit,
57
+ reviewedCommit: pkg.source.commit,
58
+ },
59
+ ];
60
+ });
61
+ const oldIds = state.profile.catalogPackages
62
+ .map((item) => item.packageId)
63
+ .sort();
64
+ const expectedPackages = expected.map((pkg) => pkg.id).sort();
65
+ const selectionChanged = oldIds.join("\0") !== expectedPackages.join("\0");
66
+ return {
67
+ installed: true,
68
+ mode: state.profile.mode,
69
+ appliedAt: state.profile.appliedAt,
70
+ expectedPackages,
71
+ missingPackages,
72
+ reviewedRevisionChanges,
73
+ needsRefresh: selectionChanged ||
74
+ missingPackages.length > 0 ||
75
+ reviewedRevisionChanges.length > 0,
76
+ evaluatedAt,
77
+ boundary,
78
+ };
79
+ }
80
+ export async function evaluateInstalledProfile(catalog) {
81
+ return evaluateInstalledProfileState(await readInstallState(), catalog);
82
+ }
83
+ export function formatInstalledProfileStatus(status) {
84
+ if (!status.installed)
85
+ return "PROFILE No saved Stable, Power, Maximum, or Custom profile yet.";
86
+ return [
87
+ `PROFILE ${status.mode?.toUpperCase()}: ${status.needsRefresh ? "reviewed changes available" : "current against reviewed catalog"}`,
88
+ `Expected repositories: ${status.expectedPackages.length}`,
89
+ ...(status.missingPackages.length
90
+ ? [`Missing: ${status.missingPackages.join(", ")}`]
91
+ : []),
92
+ ...(status.reviewedRevisionChanges.length
93
+ ? [
94
+ `Reviewed revisions changed: ${status.reviewedRevisionChanges
95
+ .map((item) => item.packageId)
96
+ .join(", ")}`,
97
+ ]
98
+ : []),
99
+ `Trust boundary: ${status.boundary}`,
100
+ ].join("\n");
101
+ }
@@ -1,6 +1,6 @@
1
1
  import { compareCatalogPackages } from "./ranking.js";
2
2
  /**
3
- * Stable is Loadout's recommended daily driver: broad enough to improve normal
3
+ * Stable is Loadout's bounded policy-selected daily driver: broad enough to improve normal
4
4
  * engineering work immediately, but bounded at skill granularity and restricted
5
5
  * to catalog sources with an identified SPDX license. Maximum remains the
6
6
  * explicit broad-library mode.
@@ -45,7 +45,8 @@ export const STABLE_BOOST_PACKAGE_IDS = Object.freeze(Object.keys(STABLE_SKILL_A
45
45
  /**
46
46
  * Trust is deliberately separate from popularity and publisher tier. No
47
47
  * bundled record is labelled human-reviewed or benchmarked until that evidence
48
- * is actually stored; Stable is the policy-recommended subset.
48
+ * is actually stored; Stable is the policy-selected subset. The stored
49
+ * `recommended` value remains for schema compatibility.
49
50
  */
50
51
  export function catalogTrustStage(pkg) {
51
52
  if (STABLE_BOOST_PACKAGE_IDS.includes(pkg.id) &&
@@ -58,6 +59,10 @@ export function catalogTrustStage(pkg) {
58
59
  return "inspected";
59
60
  return "discovered";
60
61
  }
62
+ /** Keep stored `recommended` values compatible while naming their evidence honestly. */
63
+ export function formatCatalogTrustStage(stage) {
64
+ return stage === "recommended" ? "policy-selected" : stage;
65
+ }
61
66
  export function isStableSkillSelected(packageId, skillName, targetName) {
62
67
  const selected = STABLE_SKILL_ALLOWLIST[packageId];
63
68
  if (!selected)
@@ -233,7 +238,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
233
238
  selected.delete(secondary.id);
234
239
  deferred.push(secondary);
235
240
  }
236
- warnings.push(`Stable Boost selected ${primary.displayName}; deferred ${members
241
+ warnings.push(`Loadout policy selection for Stable chose ${primary.displayName}; deferred ${members
237
242
  .slice(1)
238
243
  .map((pkg) => pkg.displayName)
239
244
  .join(", ")} because they overlap in ${family.label}.`);
@@ -242,7 +247,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
242
247
  warnings.push(`Custom selection retains the soft overlap in ${family.label}. Review ${names} before installation.`);
243
248
  }
244
249
  else {
245
- warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. ${primary.displayName} is the recommended default; review each package before installation.`);
250
+ warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. Loadout policy orders ${primary.displayName} first; review each package before installation.`);
246
251
  }
247
252
  }
248
253
  return {
@@ -96,7 +96,7 @@ export function explainCatalogScore(pkg, now = new Date()) {
96
96
  ],
97
97
  };
98
98
  }
99
- /** A deterministic ordering: review tier first, then explainable evidence, then id. */
99
+ /** A deterministic policy ordering: declared tier, bounded evidence score, then id. */
100
100
  export function compareCatalogPackages(a, b) {
101
101
  return (TIER_ORDER[b.tier] - TIER_ORDER[a.tier] ||
102
102
  explainCatalogScore(b).score - explainCatalogScore(a).score ||
@@ -0,0 +1,10 @@
1
+ import { formatSchemaError, readmeClaimManifestSchema, } from "../shared/schemas.js";
2
+ export { readmeClaimManifestSchema };
3
+ /** Parse the versioned index of evidence for material README statements. */
4
+ export function parseReadmeClaimManifest(value) {
5
+ const result = readmeClaimManifestSchema.safeParse(value);
6
+ if (!result.success) {
7
+ throw new Error(`README claim manifest is invalid: ${formatSchemaError(result.error)}`);
8
+ }
9
+ return result.data;
10
+ }
@@ -0,0 +1,40 @@
1
+ import { supportedAdapterNames } from "./adapters.js";
2
+ import { buildCatalogCoverage } from "./catalog-coverage.js";
3
+ function profileFacts(allowlist) {
4
+ return {
5
+ sources: Object.keys(allowlist).length,
6
+ skillDirectories: Object.values(allowlist).reduce((count, skills) => count + skills.length, 0),
7
+ };
8
+ }
9
+ /**
10
+ * Derive all changeable README facts from passed authoritative source data.
11
+ * This module intentionally neither reads nor parses README text.
12
+ */
13
+ export function deriveReadmeFacts({ catalog, packageJson, agents, profiles, }) {
14
+ const coverage = buildCatalogCoverage(catalog);
15
+ return {
16
+ catalog: {
17
+ records: coverage.records,
18
+ categories: coverage.categoryCount,
19
+ components: coverage.components,
20
+ installShapes: coverage.installShapes,
21
+ assertedLicenses: coverage.assertedLicenses,
22
+ noAssertionLicenses: coverage.noAssertionLicenses,
23
+ },
24
+ profiles: {
25
+ stable: profileFacts(profiles.stable),
26
+ power: profileFacts(profiles.power),
27
+ },
28
+ agents: {
29
+ supportedNames: supportedAdapterNames(agents),
30
+ },
31
+ package: {
32
+ name: packageJson.name,
33
+ version: packageJson.version,
34
+ bin: { ...packageJson.bin },
35
+ },
36
+ runtime: {
37
+ node: packageJson.engines.node,
38
+ },
39
+ };
40
+ }
@@ -17,6 +17,11 @@ const SIGNAL_FILES = new Set([
17
17
  "playwright.config.ts",
18
18
  ".git",
19
19
  ]);
20
+ /** Additive machine-readable boundary for every rule-selected recommendation list. */
21
+ export const RECOMMENDATION_BOUNDARY = Object.freeze({
22
+ selectionMethod: "deterministic-project-signal-rules",
23
+ qualityEvidence: "not-established",
24
+ });
20
25
  export async function scanProject(root = process.cwd()) {
21
26
  const absolute = resolve(root);
22
27
  const entries = await readdir(absolute, { withFileTypes: true });
@@ -132,7 +137,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
132
137
  }
133
138
  export const TESTED_PROFILES = {
134
139
  stable: {
135
- description: "Recommended 30-skill daily driver from four pinned, SPDX-identified sources with no extra static-risk approvals.",
140
+ description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
136
141
  packages: [...STABLE_BOOST_PACKAGE_IDS],
137
142
  },
138
143
  web: {
@@ -144,7 +149,7 @@ export const TESTED_PROFILES = {
144
149
  packages: ["superpowers", "context7", "github-mcp-server"],
145
150
  },
146
151
  maximum: {
147
- description: "Broad reviewed toolkit; always review MCP permissions before applying.",
152
+ description: "Broad inspected toolkit; always review MCP permissions before applying.",
148
153
  packages: [
149
154
  "superpowers",
150
155
  "context7",
@@ -171,7 +176,8 @@ export function formatRecommendations(signals, recommendations) {
171
176
  `Project: ${basename(signals.root)}`,
172
177
  `Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
173
178
  "",
174
- "Recommendations:",
179
+ "Rule-based project suggestions:",
180
+ "Rules use detected project signals and catalog membership; they do not prove package quality.",
175
181
  ];
176
182
  if (!recommendations.length)
177
183
  lines.push(" No matching catalog packages found.");
@@ -4,10 +4,12 @@ import { forgetInstall, installStatePath, readInstallState } from "./state.js";
4
4
  import { writeMcpConfigPlan } from "./mcp.js";
5
5
  import { runMutationTransaction } from "./transaction.js";
6
6
  export async function planRemove(packageId) {
7
- const record = (await readInstallState()).installs.find((entry) => entry.packageId === packageId);
8
- if (!record)
7
+ const state = await readInstallState();
8
+ const record = state.installs.find((entry) => entry.packageId === packageId);
9
+ const trackedMcp = (state.mcpInstalls ?? []).filter((entry) => entry.packageId === packageId);
10
+ if (!record && !trackedMcp.length)
9
11
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
10
- const files = await Promise.all(record.files.map(async (file) => {
12
+ const files = await Promise.all((record?.files ?? []).map(async (file) => {
11
13
  try {
12
14
  const digest = createHash("sha256")
13
15
  .update(await readFile(file.path))
@@ -24,10 +26,7 @@ export async function planRemove(packageId) {
24
26
  }
25
27
  }));
26
28
  const modified = files.filter((file) => file.status === "modified");
27
- const state = await readInstallState();
28
- const mcpServers = await Promise.all((state.mcpInstalls ?? [])
29
- .filter((entry) => entry.packageId === packageId)
30
- .map(async (entry) => {
29
+ const mcpServers = await Promise.all(trackedMcp.map(async (entry) => {
31
30
  try {
32
31
  const config = JSON.parse(await readFile(entry.configPath, "utf8"));
33
32
  if (!config.mcpServers || !(entry.serverName in config.mcpServers))
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
5
5
  import { writeFileAtomically } from "./atomic-file.js";
6
6
  import { detectAgents, loadoutHome, userHome } from "./paths.js";
7
7
  import { parseRuntimeToolRecipe, renderRuntimeRecipeValue, resolveRuntimeRecipePath, } from "./runtime-tool-recipe.js";
8
- import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
8
+ import { createSnapshot, readSnapshot, recordSnapshotPostMutationState, restoreSnapshot, } from "./snapshot.js";
9
9
  const execFileAsync = promisify(execFile);
10
10
  function deepFreeze(value) {
11
11
  if (value && typeof value === "object" && !Object.isFrozen(value)) {
@@ -217,6 +217,11 @@ async function readState(stateHome) {
217
217
  throw error;
218
218
  }
219
219
  }
220
+ /** Return runtime tools currently installed and owned by Loadout. */
221
+ export async function listInstalledRuntimeTools(stateHome = loadoutHome()) {
222
+ const state = await readState(stateHome);
223
+ return Object.keys(state.tools).sort();
224
+ }
220
225
  async function writeState(state, stateHome) {
221
226
  await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
222
227
  }
@@ -394,7 +399,9 @@ export async function applyRuntimeToolPlan(plan, options) {
394
399
  if (!installed)
395
400
  throw new Error(`${plan.recipe.displayName} is not managed by Loadout`);
396
401
  const snapshot = await readSnapshot(installed.snapshotId);
397
- await restoreSnapshot(snapshot);
402
+ await restoreSnapshot(snapshot, {
403
+ requireUnchangedPostMutationState: true,
404
+ });
398
405
  delete state.tools[plan.recipe.id];
399
406
  await writeState(state, plan.stateHome);
400
407
  return { action: "remove", snapshotId: snapshot.id };
@@ -439,6 +446,7 @@ export async function applyRuntimeToolPlan(plan, options) {
439
446
  agents: plan.agents.map((agent) => agent.id),
440
447
  runtimeRoot: plan.runtimeRoot,
441
448
  };
449
+ await recordSnapshotPostMutationState(snapshot);
442
450
  await writeState(state, plan.stateHome);
443
451
  return { action: "install", snapshotId: snapshot.id };
444
452
  }
@@ -4,7 +4,7 @@ import { dirname, win32, join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { writeFileAtomically } from "./atomic-file.js";
6
6
  import { ensureDirectory, loadoutHome, userHome } from "./paths.js";
7
- import { createSnapshot } from "./snapshot.js";
7
+ import { createSnapshot, recordSnapshotPostMutationState } from "./snapshot.js";
8
8
  import { beginTransaction, completeTransaction, markTransactionCommitting, recoverPendingTransactions, rollbackTransaction, } from "./transaction.js";
9
9
  const execFileAsync = promisify(execFile);
10
10
  function parseTime(value) {
@@ -41,7 +41,7 @@ export function planNativeScheduler(action, options = {}) {
41
41
  const job = options.job ?? "updates";
42
42
  const nativeId = `loadout-daily-${job}`;
43
43
  const command = job === "updates"
44
- ? [...launcher, "watch", "--once", "--json"]
44
+ ? [...launcher, "update", "--json"]
45
45
  : [...launcher, "discover", "--source", "all", "--queue", "--json"];
46
46
  if (selectedPlatform === "darwin") {
47
47
  const label = `com.loadout.daily.${job}`;
@@ -238,6 +238,7 @@ export async function applyNativeSchedulerBundle(plans, runner = defaultRunner)
238
238
  if (plans[0].action === "unschedule")
239
239
  for (const file of plans.flatMap((plan) => plan.files))
240
240
  await rm(file.path, { force: true });
241
+ await recordSnapshotPostMutationState(snapshot);
241
242
  await completeTransaction(transaction);
242
243
  }
243
244
  catch (error) {
@@ -259,6 +260,6 @@ export function formatNativeScheduler(plan) {
259
260
  `Command: ${plan.command.join(" ")}`,
260
261
  ...plan.files.map((file) => `File: ${file.path}`),
261
262
  ...plan.applyCommands.map((item) => `Native action: ${item.command} ${item.args.join(" ")}`),
262
- `Guarantee: the scheduled command is ${plan.command.slice(2).join(" ")}; it can only report updates or queue candidates and cannot apply changes.`,
263
+ `Guarantee: no --yes flag is scheduled; this job can only report updates or queue candidates and cannot apply changes.`,
263
264
  ].join("\n");
264
265
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, writeFile, mkdir, readdir, rm, lstat, } from "node:fs/promises";
2
+ import { readFile, writeFile, mkdir, readdir, rename, rm, lstat, } from "node:fs/promises";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { loadoutHome, ensureDirectory, userHome } from "./paths.js";
5
5
  export async function createSnapshot(paths, options = {}) {
@@ -41,7 +41,7 @@ export async function createSnapshot(paths, options = {}) {
41
41
  if (!info.isDirectory())
42
42
  throw new Error(`Refusing unsupported snapshot target: ${path}`);
43
43
  snapshot.files.push({ path, existed: true, directory: true });
44
- const entries = await readdir(path, { withFileTypes: true });
44
+ const entries = (await readdir(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
45
45
  for (const entry of entries) {
46
46
  const child = join(path, entry.name);
47
47
  if (entry.isSymbolicLink())
@@ -55,6 +55,8 @@ export async function createSnapshot(paths, options = {}) {
55
55
  content: (await readFile(child)).toString("base64"),
56
56
  encoding: "base64",
57
57
  });
58
+ else
59
+ throw new Error(`Refusing unsupported snapshot target: ${child}`);
58
60
  }
59
61
  }
60
62
  for (const path of snapshot.roots)
@@ -67,8 +69,10 @@ export async function createSnapshot(paths, options = {}) {
67
69
  }
68
70
  return snapshot;
69
71
  }
70
- export async function restoreSnapshot(snapshot) {
72
+ export async function restoreSnapshot(snapshot, options = {}) {
71
73
  validateSnapshot(snapshot);
74
+ if (options.requireUnchangedPostMutationState)
75
+ await assertUnchangedPostMutationState(snapshot);
72
76
  for (const root of snapshot.roots)
73
77
  await rm(root, { recursive: true, force: true });
74
78
  for (const directory of snapshot.files
@@ -84,6 +88,39 @@ export async function restoreSnapshot(snapshot) {
84
88
  : (file.content ?? ""));
85
89
  }
86
90
  }
91
+ /** Attach the committed state used to make later user-requested rollback safe. */
92
+ export async function recordSnapshotPostMutationState(snapshot) {
93
+ const postMutation = await createSnapshot(snapshot.roots, { persist: false });
94
+ snapshot.postMutationFiles = postMutation.files;
95
+ validateSnapshot(snapshot);
96
+ const directory = join(loadoutHome(), "snapshots");
97
+ await ensureDirectory(directory);
98
+ const target = join(directory, `${snapshot.id}.json`);
99
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
100
+ await writeFile(temporary, JSON.stringify(snapshot, null, 2), {
101
+ mode: 0o600,
102
+ flag: "wx",
103
+ });
104
+ await rename(temporary, target);
105
+ }
106
+ async function assertUnchangedPostMutationState(snapshot) {
107
+ if (!snapshot.postMutationFiles)
108
+ throw new Error("Explicit rollback refused: this legacy snapshot has no post-mutation evidence. Preserve current files and use a newer snapshot.");
109
+ let current;
110
+ try {
111
+ current = (await createSnapshot(snapshot.roots, { persist: false })).files;
112
+ }
113
+ catch (error) {
114
+ throw new Error(`Explicit rollback refused because the current filesystem cannot be verified: ${error instanceof Error ? error.message : String(error)}`);
115
+ }
116
+ const expected = new Map(snapshot.postMutationFiles.map((file) => [file.path, file]));
117
+ const actual = new Map(current.map((file) => [file.path, file]));
118
+ const changed = [...new Set([...expected.keys(), ...actual.keys()])]
119
+ .filter((path) => JSON.stringify(expected.get(path)) !== JSON.stringify(actual.get(path)))
120
+ .sort();
121
+ if (changed.length)
122
+ throw new Error(`Explicit rollback refused because files changed after the snapshot: ${changed.slice(0, 10).join(", ")}. Preserve or review these changes before rollback.`);
123
+ }
87
124
  export async function readSnapshot(id) {
88
125
  if (!isSnapshotId(id))
89
126
  throw new Error(`Invalid snapshot id: ${id}`);
@@ -167,42 +204,50 @@ export function validateSnapshot(value) {
167
204
  if (roots.some((candidate, candidateIndex) => candidateIndex !== index && isInside(candidate, root)))
168
205
  throw new Error("Snapshot roots must be unique and non-overlapping");
169
206
  }
207
+ validateSnapshotFiles(value.files, roots, "Snapshot");
208
+ if (value.postMutationFiles !== undefined) {
209
+ if (!Array.isArray(value.postMutationFiles))
210
+ throw new Error("Snapshot post-mutation files are invalid");
211
+ validateSnapshotFiles(value.postMutationFiles, roots, "Snapshot post-mutation");
212
+ }
213
+ return value;
214
+ }
215
+ function validateSnapshotFiles(files, roots, label) {
170
216
  const paths = new Set();
171
- for (const [index, file] of value.files.entries()) {
217
+ for (const [index, file] of files.entries()) {
172
218
  if (!isRecord(file) ||
173
219
  typeof file.path !== "string" ||
174
220
  typeof file.existed !== "boolean" ||
175
221
  (file.directory !== undefined && typeof file.directory !== "boolean") ||
176
222
  (file.content !== undefined && typeof file.content !== "string") ||
177
223
  (file.encoding !== undefined && file.encoding !== "base64"))
178
- throw new Error(`Snapshot file ${index} is invalid`);
224
+ throw new Error(`${label} file ${index} is invalid`);
179
225
  const filePath = file.path;
180
226
  if (resolve(filePath) !== filePath)
181
- throw new Error(`Snapshot file ${index} path must be absolute and normalized`);
227
+ throw new Error(`${label} file ${index} path must be absolute and normalized`);
182
228
  if (paths.has(filePath))
183
- throw new Error(`Snapshot file ${index} duplicates another path`);
229
+ throw new Error(`${label} file ${index} duplicates another path`);
184
230
  paths.add(filePath);
185
231
  if (!roots.some((root) => isInside(root, filePath)))
186
- throw new Error(`Snapshot file ${index} escapes its declared roots`);
232
+ throw new Error(`${label} file ${index} escapes its declared roots`);
187
233
  if (!file.existed) {
188
234
  if (file.directory !== undefined ||
189
235
  file.content !== undefined ||
190
236
  file.encoding !== undefined)
191
- throw new Error(`Missing snapshot file ${index} must not contain data`);
237
+ throw new Error(`Missing ${label.toLowerCase()} file ${index} must not contain data`);
192
238
  }
193
239
  else if (file.directory) {
194
240
  if (file.content !== undefined || file.encoding !== undefined)
195
- throw new Error(`Snapshot directory ${index} must not contain bytes`);
241
+ throw new Error(`${label} directory ${index} must not contain bytes`);
196
242
  }
197
243
  else if (typeof file.content !== "string" ||
198
244
  file.encoding !== "base64" ||
199
245
  !isCanonicalBase64(file.content))
200
- throw new Error(`Snapshot file ${index} bytes are invalid`);
246
+ throw new Error(`${label} file ${index} bytes are invalid`);
201
247
  }
202
248
  for (const [index, root] of roots.entries())
203
249
  if (!paths.has(root))
204
- throw new Error(`Snapshot root ${index} has no matching file record`);
205
- return value;
250
+ throw new Error(`${label} root ${index} has no matching file record`);
206
251
  }
207
252
  function isCanonicalBase64(value) {
208
253
  if (value.length % 4 !== 0)
@@ -123,7 +123,7 @@ export async function hashDirectory(root) {
123
123
  await visit(root);
124
124
  return files;
125
125
  }
126
- export async function recordInstall(plan, snapshotId, metadata = {}) {
126
+ export async function recordInstall(plan, snapshotId, metadata = {}, options = {}) {
127
127
  const record = await createInstallRecord(plan, snapshotId, metadata);
128
128
  const state = await readInstallState();
129
129
  state.installs = [
@@ -131,6 +131,13 @@ export async function recordInstall(plan, snapshotId, metadata = {}) {
131
131
  record,
132
132
  ];
133
133
  state.activations = mergeActivationRecords(state.activations, activationRecordsForPlan(plan, metadata));
134
+ await options.verifyBeforeWrite?.();
135
+ if (options.expectedFiles) {
136
+ const actual = [...record.files].sort((left, right) => left.path.localeCompare(right.path));
137
+ const expected = [...options.expectedFiles].sort((left, right) => left.path.localeCompare(right.path));
138
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
139
+ throw new Error("Installed files changed before ownership could be recorded.");
140
+ }
134
141
  await writeInstallState(state);
135
142
  return record;
136
143
  }
@@ -298,12 +305,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
298
305
  export async function forgetInstall(packageId) {
299
306
  const state = await readInstallState();
300
307
  const installs = state.installs.filter((entry) => entry.packageId !== packageId);
301
- if (installs.length === state.installs.length)
308
+ const mcpInstalls = (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId);
309
+ if (installs.length === state.installs.length &&
310
+ mcpInstalls.length === (state.mcpInstalls ?? []).length)
302
311
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
303
312
  await writeInstallState({
304
313
  version: 1,
305
314
  installs,
306
- mcpInstalls: (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId),
315
+ ...(state.profile ? { profile: state.profile } : {}),
316
+ mcpInstalls,
307
317
  activations: (state.activations ?? []).map((entry) => entry.packageId === packageId
308
318
  ? {
309
319
  ...entry,
@@ -2,7 +2,7 @@ import { lstat, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { ensureDirectory, loadoutHome } from "./paths.js";
5
- import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
5
+ import { createSnapshot, readSnapshot, recordSnapshotPostMutationState, restoreSnapshot, } from "./snapshot.js";
6
6
  import { acquireFileLock, withFileLock, } from "./file-lock.js";
7
7
  export const transactionRoot = () => join(loadoutHome(), "staging");
8
8
  const transactionPreparingRoot = () => join(loadoutHome(), "staging-preparing");
@@ -136,6 +136,7 @@ export async function runMutationTransaction(prepare, mutate) {
136
136
  });
137
137
  await markTransactionCommitting(transaction);
138
138
  const result = await mutate(prepared.value, snapshot);
139
+ await recordSnapshotPostMutationState(snapshot);
139
140
  await completeTransaction(transaction);
140
141
  return { snapshotId: snapshot.id, result };
141
142
  }