loadout-ai 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/MASTER_PLAN.md +177 -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/active-policy.js +172 -38
  8. package/dist/src/core/active-set.js +13 -4
  9. package/dist/src/core/adapters.js +10 -0
  10. package/dist/src/core/adopt.js +165 -32
  11. package/dist/src/core/agent-health-score.js +2 -2
  12. package/dist/src/core/catalog-coverage.js +2 -1
  13. package/dist/src/core/catalog-install.js +8 -1
  14. package/dist/src/core/catalog-release.js +2 -1
  15. package/dist/src/core/conformance.js +74 -0
  16. package/dist/src/core/install.js +11 -11
  17. package/dist/src/core/profiles.js +9 -4
  18. package/dist/src/core/ranking.js +1 -1
  19. package/dist/src/core/readme-claims.js +10 -0
  20. package/dist/src/core/readme-facts.js +40 -0
  21. package/dist/src/core/recommend.js +104 -12
  22. package/dist/src/core/runtime-tools.js +5 -2
  23. package/dist/src/core/scheduler.js +2 -1
  24. package/dist/src/core/snapshot.js +58 -13
  25. package/dist/src/core/state.js +8 -1
  26. package/dist/src/core/target-occupancy.js +50 -0
  27. package/dist/src/core/transaction.js +2 -1
  28. package/dist/src/core/uninstall.js +26 -2
  29. package/dist/src/dashboard.js +5 -2
  30. package/dist/src/shared/schemas.js +57 -0
  31. package/docs/FEATURE_TEST_MATRIX.md +16 -0
  32. package/docs/README_RESEARCH.md +36 -0
  33. package/docs/RELEASE_REVIEW.md +31 -5
  34. package/docs/REPOSITORY_STABILIZATION.md +190 -0
  35. package/docs/TESTING.md +50 -0
  36. package/docs/USER_TEST_GUIDE.md +42 -4
  37. package/docs/assets/loadout-hero.svg +259 -0
  38. package/docs/assets/loadout-mark.svg +54 -0
  39. package/docs/evidence/live-checks-2026-07-19.json +22 -0
  40. package/docs/evidence/live-checks.schema.json +28 -0
  41. package/docs/evidence/readme-claims.json +286 -0
  42. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
  43. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
  44. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
  45. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
  46. package/package.json +8 -4
  47. package/SIMPLE_PLAN.md +0 -44
  48. package/docs/plans/2026-07-18-release-0.3.md +0 -42
  49. package/docs/superpowers/plans/2026-07-18-cli-ux-polish.md +0 -86
@@ -15,8 +15,53 @@ const SIGNAL_FILES = new Set([
15
15
  "next.config.mjs",
16
16
  "vite.config.ts",
17
17
  "playwright.config.ts",
18
+ "SECURITY.md",
18
19
  ".git",
19
20
  ]);
21
+ /** Additive machine-readable boundary for every rule-selected recommendation list. */
22
+ export const RECOMMENDATION_BOUNDARY = Object.freeze({
23
+ selectionMethod: "deterministic-project-signal-rules",
24
+ qualityEvidence: "not-established",
25
+ });
26
+ const SIGNAL_LABELS = {
27
+ "javascript/typescript": "TypeScript",
28
+ playwright: "Playwright",
29
+ "node-cli": "Node CLI",
30
+ "npm-package": "npm package",
31
+ release: "release automation",
32
+ mcp: "MCP tooling",
33
+ security: "security policy",
34
+ commander: "Commander",
35
+ zod: "Zod",
36
+ vitest: "Vitest",
37
+ jest: "Jest",
38
+ };
39
+ const SIGNAL_DISPLAY_ORDER = [
40
+ "javascript/typescript",
41
+ "playwright",
42
+ "node-cli",
43
+ "npm-package",
44
+ "release",
45
+ "mcp",
46
+ "security",
47
+ "commander",
48
+ "zod",
49
+ "vitest",
50
+ "jest",
51
+ ];
52
+ export function formatDetectedSignals(signals) {
53
+ const values = new Set([
54
+ ...signals.languages,
55
+ ...signals.frameworks,
56
+ ...signals.roles,
57
+ ...signals.tools,
58
+ ]);
59
+ const ordered = [
60
+ ...SIGNAL_DISPLAY_ORDER.filter((value) => values.delete(value)),
61
+ ...values,
62
+ ];
63
+ return ordered.map((value) => SIGNAL_LABELS[value] ?? value).join(", ");
64
+ }
20
65
  export async function scanProject(root = process.cwd()) {
21
66
  const absolute = resolve(root);
22
67
  const entries = await readdir(absolute, { withFileTypes: true });
@@ -26,6 +71,8 @@ export async function scanProject(root = process.cwd()) {
26
71
  .sort();
27
72
  const languages = new Set();
28
73
  const frameworks = new Set();
74
+ const roles = new Set();
75
+ const tools = new Set();
29
76
  if (files.includes("package.json")) {
30
77
  languages.add("javascript/typescript");
31
78
  try {
@@ -39,8 +86,28 @@ export async function scanProject(root = process.cwd()) {
39
86
  frameworks.add("vue");
40
87
  if (deps.svelte)
41
88
  frameworks.add("svelte");
42
- if (deps.playwright || deps["@playwright/test"])
89
+ if (deps.playwright || deps["@playwright/test"]) {
43
90
  frameworks.add("playwright");
91
+ tools.add("playwright");
92
+ }
93
+ if (deps.vitest)
94
+ tools.add("vitest");
95
+ if (deps.jest)
96
+ tools.add("jest");
97
+ if (deps.commander)
98
+ tools.add("commander");
99
+ if (deps.zod)
100
+ tools.add("zod");
101
+ if (pkg.bin)
102
+ roles.add("node-cli");
103
+ if (pkg.publishConfig || pkg.private === false)
104
+ roles.add("npm-package");
105
+ const scripts = pkg.scripts ?? {};
106
+ if (scripts.prepack ||
107
+ Object.keys(scripts).some((name) => /(?:package|release)/.test(name)))
108
+ roles.add("release");
109
+ if ((pkg.keywords ?? []).some((keyword) => /(?:^|-)mcp(?:-|$)/i.test(keyword)))
110
+ roles.add("mcp");
44
111
  }
45
112
  catch {
46
113
  /* malformed project metadata is reported through an empty framework set */
@@ -60,22 +127,37 @@ export async function scanProject(root = process.cwd()) {
60
127
  languages.add(".net");
61
128
  if (files.some((file) => file.startsWith("next.config")))
62
129
  frameworks.add("next.js");
63
- if (files.includes("playwright.config.ts"))
130
+ if (files.includes("playwright.config.ts")) {
64
131
  frameworks.add("playwright");
132
+ tools.add("playwright");
133
+ }
134
+ if (files.includes("SECURITY.md"))
135
+ roles.add("security");
65
136
  return {
66
137
  root: absolute,
67
138
  languages: [...languages],
68
139
  frameworks: [...frameworks],
140
+ roles: [...roles],
141
+ tools: [...tools],
69
142
  files,
70
143
  };
71
144
  }
72
145
  export function recommendPackages(signals, catalog) {
73
- const ids = new Set(catalog.map((pkg) => pkg.id));
146
+ const packages = new Map(catalog.map((pkg) => [pkg.id, pkg]));
74
147
  const result = [];
75
148
  const add = (packageId, reason, confidence) => {
76
- if (ids.has(packageId) &&
77
- !result.some((item) => item.packageId === packageId))
78
- result.push({ packageId, reason, confidence });
149
+ const pkg = packages.get(packageId);
150
+ if (pkg && !result.some((item) => item.packageId === packageId))
151
+ result.push({
152
+ packageId,
153
+ reason,
154
+ confidence,
155
+ kind: pkg.components?.includes("skill")
156
+ ? "skill-library"
157
+ : pkg.components?.some((component) => component === "mcp" || component === "plugin")
158
+ ? "mcp-runtime"
159
+ : "unavailable",
160
+ });
79
161
  };
80
162
  add("superpowers", "Useful engineering planning, testing, and review workflows for most repositories.", "high");
81
163
  add("context7", "Current library documentation helps agents avoid outdated APIs.", signals.languages.length ? "high" : "medium");
@@ -124,6 +206,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
124
206
  packageId: item.packageId,
125
207
  reason: item.reason,
126
208
  confidence: item.confidence,
209
+ kind: item.kind,
127
210
  ...(item.localOutcomeAdjustment !== undefined
128
211
  ? { localOutcomeAdjustment: item.localOutcomeAdjustment }
129
212
  : {}),
@@ -132,7 +215,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
132
215
  }
133
216
  export const TESTED_PROFILES = {
134
217
  stable: {
135
- description: "Recommended 30-skill daily driver from four pinned, SPDX-identified sources with no extra static-risk approvals.",
218
+ description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
136
219
  packages: [...STABLE_BOOST_PACKAGE_IDS],
137
220
  },
138
221
  web: {
@@ -144,7 +227,7 @@ export const TESTED_PROFILES = {
144
227
  packages: ["superpowers", "context7", "github-mcp-server"],
145
228
  },
146
229
  maximum: {
147
- description: "Broad reviewed toolkit; always review MCP permissions before applying.",
230
+ description: "Broad inspected toolkit; always review MCP permissions before applying.",
148
231
  packages: [
149
232
  "superpowers",
150
233
  "context7",
@@ -169,13 +252,22 @@ export function profileManifestPackages(profile, catalog) {
169
252
  export function formatRecommendations(signals, recommendations) {
170
253
  const lines = [
171
254
  `Project: ${basename(signals.root)}`,
172
- `Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
255
+ `Detected: ${formatDetectedSignals(signals) || "no known project signals"}`,
173
256
  "",
174
- "Recommendations:",
257
+ "Rule-based project suggestions:",
258
+ "Rules use detected project signals and catalog membership; they do not prove package quality.",
175
259
  ];
176
260
  if (!recommendations.length)
177
261
  lines.push(" No matching catalog packages found.");
178
- for (const item of recommendations)
179
- lines.push(` ${item.packageId} [${item.confidence}] — ${item.reason}`);
262
+ const kindLabels = {
263
+ "skill-library": "skill library",
264
+ "mcp-runtime": "MCP/runtime setup",
265
+ unavailable: "unavailable",
266
+ };
267
+ for (const item of recommendations) {
268
+ lines.push(` ${item.packageId} [${item.confidence}, ${kindLabels[item.kind]}] — ${item.reason}`);
269
+ if (item.kind === "mcp-runtime")
270
+ lines.push(" Explicit setup only; preview credentials and permissions before enabling it.");
271
+ }
180
272
  return lines.join("\n");
181
273
  }
@@ -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
  }
@@ -0,0 +1,50 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Inspect a prospective skill target without following symlinks or executing
5
+ * any content. Missing paths and recursively empty directories are safe to
6
+ * replace; every uncertain state fails closed.
7
+ */
8
+ export async function inspectTargetOccupancy(path, maximumEntries = 10_000) {
9
+ let root;
10
+ try {
11
+ root = await lstat(path);
12
+ }
13
+ catch (error) {
14
+ if (error &&
15
+ typeof error === "object" &&
16
+ "code" in error &&
17
+ error.code === "ENOENT")
18
+ return { occupied: false };
19
+ return { occupied: true, reason: "unreadable" };
20
+ }
21
+ if (root.isSymbolicLink())
22
+ return { occupied: true, reason: "symlink" };
23
+ if (!root.isDirectory())
24
+ return { occupied: true, reason: "unsupported" };
25
+ const queue = [path];
26
+ let inspected = 0;
27
+ while (queue.length) {
28
+ const directory = queue.pop();
29
+ let entries;
30
+ try {
31
+ entries = await readdir(directory, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ return { occupied: true, reason: "unreadable" };
35
+ }
36
+ for (const entry of entries) {
37
+ inspected += 1;
38
+ if (inspected > maximumEntries)
39
+ return { occupied: true, reason: "inspection-limit" };
40
+ if (entry.isDirectory() && !entry.isSymbolicLink())
41
+ queue.push(join(directory, entry.name));
42
+ else
43
+ return {
44
+ occupied: true,
45
+ reason: entry.isSymbolicLink() ? "symlink" : "content",
46
+ };
47
+ }
48
+ }
49
+ return { occupied: false };
50
+ }
@@ -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