loadout-ai 0.3.1 → 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 (44) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/MASTER_PLAN.md +86 -13
  3. package/README.md +157 -284
  4. package/dashboard/app.js +4 -4
  5. package/dashboard/index.html +4 -4
  6. package/dist/src/cli.js +27 -21
  7. package/dist/src/core/adapters.js +10 -0
  8. package/dist/src/core/adopt.js +165 -32
  9. package/dist/src/core/agent-health-score.js +2 -2
  10. package/dist/src/core/catalog-coverage.js +2 -1
  11. package/dist/src/core/catalog-install.js +8 -1
  12. package/dist/src/core/catalog-release.js +2 -1
  13. package/dist/src/core/conformance.js +74 -0
  14. package/dist/src/core/install.js +36 -3
  15. package/dist/src/core/profiles.js +9 -4
  16. package/dist/src/core/ranking.js +1 -1
  17. package/dist/src/core/readme-claims.js +10 -0
  18. package/dist/src/core/readme-facts.js +40 -0
  19. package/dist/src/core/recommend.js +9 -3
  20. package/dist/src/core/runtime-tools.js +5 -2
  21. package/dist/src/core/scheduler.js +2 -1
  22. package/dist/src/core/snapshot.js +58 -13
  23. package/dist/src/core/state.js +8 -1
  24. package/dist/src/core/transaction.js +2 -1
  25. package/dist/src/core/uninstall.js +26 -2
  26. package/dist/src/dashboard.js +5 -2
  27. package/dist/src/shared/schemas.js +57 -0
  28. package/docs/FEATURE_TEST_MATRIX.md +16 -0
  29. package/docs/README_RESEARCH.md +36 -0
  30. package/docs/RELEASE_REVIEW.md +31 -5
  31. package/docs/REPOSITORY_STABILIZATION.md +190 -0
  32. package/docs/TESTING.md +50 -0
  33. package/docs/USER_TEST_GUIDE.md +26 -0
  34. package/docs/assets/loadout-hero.svg +259 -0
  35. package/docs/assets/loadout-mark.svg +54 -0
  36. package/docs/evidence/live-checks-2026-07-19.json +22 -0
  37. package/docs/evidence/live-checks.schema.json +28 -0
  38. package/docs/evidence/readme-claims.json +286 -0
  39. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
  40. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
  41. package/package.json +8 -4
  42. package/SIMPLE_PLAN.md +0 -44
  43. package/docs/plans/2026-07-18-release-0.3.md +0 -42
  44. package/docs/superpowers/plans/2026-07-18-cli-ux-polish.md +0 -86
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { cp, lstat, rm } from "node:fs/promises";
2
+ import { cp, lstat, readdir, rm } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, posix, relative, win32, } from "node:path";
4
4
  import { ensureDirectory, loadoutHome } from "./paths.js";
5
5
  import { planAdapterSkillInstall } from "./adapters.js";
@@ -117,7 +117,32 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
117
117
  ...new Set(plans.flatMap((plan) => plan.files.map((file) => file.target))),
118
118
  ])
119
119
  try {
120
- await lstat(target);
120
+ const info = await lstat(target);
121
+ if (info.isDirectory() && !info.isSymbolicLink()) {
122
+ const queue = [target];
123
+ let entriesChecked = 0;
124
+ let empty = true;
125
+ while (queue.length && empty) {
126
+ const directory = queue.pop();
127
+ for (const entry of await readdir(directory, {
128
+ withFileTypes: true,
129
+ })) {
130
+ entriesChecked += 1;
131
+ if (entriesChecked > 10_000) {
132
+ empty = false;
133
+ break;
134
+ }
135
+ if (entry.isDirectory() && !entry.isSymbolicLink())
136
+ queue.push(join(directory, entry.name));
137
+ else {
138
+ empty = false;
139
+ break;
140
+ }
141
+ }
142
+ }
143
+ if (empty)
144
+ continue;
145
+ }
121
146
  occupied.push(target);
122
147
  }
123
148
  catch (error) {
@@ -202,6 +227,9 @@ export async function applySkillInstall(plan, metadata, options = {}) {
202
227
  value: plan,
203
228
  };
204
229
  }, async (freshPlan, snapshot) => {
230
+ // Close the preview/apply race: an empty target may have become occupied
231
+ // after the first check but before the transaction snapshot completed.
232
+ await assertActiveTargetsUnoccupied([freshPlan], options);
205
233
  if (options.replaceManagedTargets)
206
234
  for (const target of [
207
235
  ...new Set(freshPlan.files.map((file) => file.target)),
@@ -264,6 +292,9 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
264
292
  value: { entries, reconciliation },
265
293
  };
266
294
  }, async ({ entries: freshEntries, reconciliation }, snapshot) => {
295
+ // Re-check immediately before any removal or copy for the same reason as
296
+ // the single-package path above.
297
+ await assertActiveTargetsUnoccupied(freshEntries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
267
298
  for (const target of reconciliation.obsoleteTargets)
268
299
  await rm(target, { recursive: true, force: true });
269
300
  if (options.replaceManagedTargets)
@@ -279,6 +310,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
279
310
  await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
280
311
  await recordInstallBatch(freshEntries, snapshot.id);
281
312
  await recordManagedProfileReconciliation(reconciliation);
313
+ await options.afterRecord?.();
282
314
  });
283
315
  return applied.snapshotId;
284
316
  }
@@ -286,7 +318,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
286
318
  * Download a batch into the reviewed library without exposing any skill to an
287
319
  * agent yet. This is the safe destination for Maximum Library.
288
320
  */
289
- export async function applySkillLibraryBatch(entries) {
321
+ export async function applySkillLibraryBatch(entries, options = {}) {
290
322
  if (!entries.length)
291
323
  throw new Error("Library batch is empty");
292
324
  const conflicts = detectInstallConflicts(entries.map((entry) => entry.plan));
@@ -343,6 +375,7 @@ export async function applySkillLibraryBatch(entries) {
343
375
  }
344
376
  }
345
377
  await recordLibraryInstallBatch(freshEntries, snapshot.id);
378
+ await options.afterRecord?.();
346
379
  });
347
380
  return applied.snapshotId;
348
381
  }
@@ -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.");
@@ -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)) {
@@ -399,7 +399,9 @@ export async function applyRuntimeToolPlan(plan, options) {
399
399
  if (!installed)
400
400
  throw new Error(`${plan.recipe.displayName} is not managed by Loadout`);
401
401
  const snapshot = await readSnapshot(installed.snapshotId);
402
- await restoreSnapshot(snapshot);
402
+ await restoreSnapshot(snapshot, {
403
+ requireUnchangedPostMutationState: true,
404
+ });
403
405
  delete state.tools[plan.recipe.id];
404
406
  await writeState(state, plan.stateHome);
405
407
  return { action: "remove", snapshotId: snapshot.id };
@@ -444,6 +446,7 @@ export async function applyRuntimeToolPlan(plan, options) {
444
446
  agents: plan.agents.map((agent) => agent.id),
445
447
  runtimeRoot: plan.runtimeRoot,
446
448
  };
449
+ await recordSnapshotPostMutationState(snapshot);
447
450
  await writeState(state, plan.stateHome);
448
451
  return { action: "install", snapshotId: snapshot.id };
449
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) {
@@ -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) {
@@ -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
  }
@@ -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
  }
@@ -40,8 +40,32 @@ async function removeEmptyManagedDirectories(plans) {
40
40
  ].sort((left, right) => right.length - left.length);
41
41
  for (const directory of candidates) {
42
42
  try {
43
- if ((await readdir(directory)).length === 0)
44
- await rmdir(directory);
43
+ const queue = [directory];
44
+ const visited = [directory];
45
+ let entriesChecked = 0;
46
+ let empty = true;
47
+ while (queue.length && empty) {
48
+ const current = queue.pop();
49
+ for (const entry of await readdir(current, { withFileTypes: true })) {
50
+ entriesChecked += 1;
51
+ if (entriesChecked > 10_000) {
52
+ empty = false;
53
+ break;
54
+ }
55
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
56
+ const child = resolve(current, entry.name);
57
+ queue.push(child);
58
+ visited.push(child);
59
+ }
60
+ else {
61
+ empty = false;
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ if (empty)
67
+ for (const current of visited.sort((left, right) => right.length - left.length))
68
+ await rmdir(current);
45
69
  }
46
70
  catch {
47
71
  // Missing and non-empty directories are both safe to leave alone.
@@ -8,7 +8,7 @@ import { inspectAgents } from "./core/agent-inspection.js";
8
8
  import { loadEffectiveCatalog, rankCatalog } from "./core/catalog.js";
9
9
  import { buildUpdatePlan } from "./core/update.js";
10
10
  import { buildHealthReport } from "./core/health.js";
11
- import { recommendPackages, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
11
+ import { recommendPackages, RECOMMENDATION_BOUNDARY, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
12
12
  import { searchLocalRegistry } from "./core/registry.js";
13
13
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
14
  import { applySyncPlan, buildSyncPlan } from "./core/sync.js";
@@ -318,6 +318,7 @@ async function route(request, response, context) {
318
318
  const signals = await scanProject(process.cwd());
319
319
  await sendJson(response, 200, {
320
320
  signals,
321
+ recommendationBoundary: RECOMMENDATION_BOUNDARY,
321
322
  recommendations: recommendPackages(signals, await loadEffectiveCatalog()),
322
323
  });
323
324
  return;
@@ -368,7 +369,9 @@ export function createDashboardServer(options = {}) {
368
369
  buildSync: options.buildSync ?? buildSyncPlan,
369
370
  applySync: options.applySync ?? applySyncPlan,
370
371
  rollback: options.rollback ??
371
- (async (snapshotId) => withMutationLock(async () => restoreSnapshot(await readSnapshot(snapshotId)))),
372
+ (async (snapshotId) => withMutationLock(async () => restoreSnapshot(await readSnapshot(snapshotId), {
373
+ requireUnchangedPostMutationState: true,
374
+ }))),
372
375
  token: randomBytes(32).toString("hex"),
373
376
  };
374
377
  return createServer((request, response) => {
@@ -41,8 +41,26 @@ export const componentCompatibilitySchema = z.enum([
41
41
  "unsupported",
42
42
  ]);
43
43
  export const safetyRiskLevelSchema = z.enum(["safe", "review", "blocked"]);
44
+ export const readmeClaimEvidenceClassSchema = z.enum([
45
+ "structural",
46
+ "unit-verified",
47
+ "integration-verified",
48
+ "live-verified",
49
+ "platform-verified",
50
+ "human-reviewed",
51
+ "benchmarked",
52
+ "policy-selected",
53
+ ]);
54
+ export const readmeClaimStatusSchema = z.enum([
55
+ "proven",
56
+ "bounded",
57
+ "unfulfilled",
58
+ ]);
44
59
  const text = z.string().trim().min(1, "must not be empty");
45
60
  const optionalText = text.optional();
61
+ const readmeClaimIdSchema = z
62
+ .string()
63
+ .regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/, "must be a safe dotted identifier");
46
64
  const sha256 = z.string().regex(/^[a-f0-9]{64}$/i, "must be a SHA-256 hash");
47
65
  const gitSha = z.string().regex(/^[a-f0-9]{40}$/i, "must be a full Git SHA");
48
66
  const repository = z
@@ -348,6 +366,45 @@ export const loadoutLockfileSchema = z
348
366
  .optional(),
349
367
  })
350
368
  .passthrough();
369
+ export const readmeClaimSchema = z
370
+ .object({
371
+ id: readmeClaimIdSchema,
372
+ section: text,
373
+ summary: text,
374
+ evidenceClass: readmeClaimEvidenceClassSchema,
375
+ status: readmeClaimStatusSchema,
376
+ evidence: z.array(text),
377
+ externalPrerequisites: z.array(text).optional(),
378
+ })
379
+ .strict()
380
+ .superRefine((claim, context) => {
381
+ if (claim.status === "proven" && claim.evidence.length === 0) {
382
+ context.addIssue({
383
+ code: "custom",
384
+ path: ["evidence"],
385
+ message: "proven claims require at least one authoritative evidence reference",
386
+ });
387
+ }
388
+ });
389
+ export const readmeClaimManifestSchema = z
390
+ .object({
391
+ schemaVersion: z.literal(1),
392
+ claims: z.array(readmeClaimSchema).min(1),
393
+ })
394
+ .strict()
395
+ .superRefine((manifest, context) => {
396
+ const seen = new Set();
397
+ for (const [index, claim] of manifest.claims.entries()) {
398
+ if (seen.has(claim.id)) {
399
+ context.addIssue({
400
+ code: "custom",
401
+ path: ["claims", index, "id"],
402
+ message: "must be unique",
403
+ });
404
+ }
405
+ seen.add(claim.id);
406
+ }
407
+ });
351
408
  /** Compact, path-aware errors suitable for CLI and persisted-data diagnostics. */
352
409
  export function formatSchemaError(error) {
353
410
  return error.issues
@@ -121,8 +121,10 @@ reruns and diagnosis.
121
121
  | `npm run format:check` | Repository formatting | Exit 0; no files changed. |
122
122
  | `npm run lint` | TypeScript lint rules | Exit 0. |
123
123
  | `npm run typecheck` | TypeScript contract | Exit 0. |
124
+ | `npm run check:evidence` | Catalog/discovery attribution, README claims, and release boundaries | Exit 0; no claim is silently promoted. |
124
125
  | `npm test` | Unit, integration, native filesystem, safety, and regression suites | All tests pass. |
125
126
  | `npm run test:e2e:cli` | Disposable scan → compare → optimize → apply → rollback journey | Prints a successful CLI product flow. |
127
+ | `npm run test:e2e:readme` | Isolated library/activation/manifest/card/rollback journey | Prints README product flow success. |
126
128
  | `npm run test:package` | `npm pack`, install outside the checkout, packaged CLI install/rollback | Prints package smoke success. |
127
129
  | `npm run test:performance` | Seven scans of 1,000 real on-disk skill directories | p95 remains below the enforced five-second budget. |
128
130
  | `npm run test:e2e:dashboard` | Loopback dashboard first-run browser test | Playwright passes; no real profile is used. |
@@ -131,6 +133,20 @@ The dashboard test needs a Playwright browser. If the browser executable is abse
131
133
  run `npx playwright install chromium` once; that download is not a Loadout product
132
134
  side effect.
133
135
 
136
+ The focused regression contract for the v0.3.x profile lifecycle is:
137
+
138
+ ```bash
139
+ npx vitest run tests/upgrade.test.ts tests/profile-state.test.ts \
140
+ tests/update.test.ts tests/uninstall.test.ts tests/install.test.ts \
141
+ tests/mcp-recipes.test.ts tests/cli-help.test.ts
142
+ ```
143
+
144
+ It verifies preview/apply upgrade behavior, saved-profile refresh detection, safe
145
+ profile reconciliation, complete-uninstall drift protection, recursively empty target
146
+ recovery, and the separation between model-provider access declarations and service
147
+ credentials. These are disposable automated filesystem tests; they do not prove that
148
+ a native agent consumes the resulting files or that a host scheduler/keychain works.
149
+
134
150
  ## 2. Read-only inventory, ranking, and recommendation track (R; some N)
135
151
 
136
152
  ```bash