loadout-ai 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/MASTER_PLAN.md +173 -9
  3. package/README.md +57 -19
  4. package/catalog/discovered.json +8422 -6957
  5. package/catalog/packages.json +26 -0
  6. package/dist/src/cli.js +140 -68
  7. package/dist/src/core/active-policy.js +216 -42
  8. package/dist/src/core/active-set.js +14 -5
  9. package/dist/src/core/adopt.js +1 -1
  10. package/dist/src/core/catalog-coverage.js +2 -1
  11. package/dist/src/core/catalog-install.js +23 -8
  12. package/dist/src/core/cli-guide.js +5 -2
  13. package/dist/src/core/codex-mcp.js +49 -5
  14. package/dist/src/core/completion.js +0 -3
  15. package/dist/src/core/health.js +19 -11
  16. package/dist/src/core/install.js +6 -39
  17. package/dist/src/core/mcp-recipes.js +13 -4
  18. package/dist/src/core/mcp.js +6 -5
  19. package/dist/src/core/model-config.js +1 -1
  20. package/dist/src/core/portable.js +1 -1
  21. package/dist/src/core/recommend.js +129 -27
  22. package/dist/src/core/remove.js +28 -2
  23. package/dist/src/core/runtime-tool-recipe.js +2 -2
  24. package/dist/src/core/runtime-tools.js +15 -4
  25. package/dist/src/core/snapshot.js +20 -0
  26. package/dist/src/core/state.js +9 -0
  27. package/dist/src/core/sync.js +1 -1
  28. package/dist/src/core/target-occupancy.js +50 -0
  29. package/dist/src/core/transaction.js +2 -1
  30. package/dist/src/shared/schemas.js +1 -0
  31. package/docs/CATALOG.md +3 -2
  32. package/docs/DISCOVERED.md +253 -255
  33. package/docs/FEATURE_TEST_MATRIX.md +19 -32
  34. package/docs/GITHUB_AUTHORIZATION.md +2 -2
  35. package/docs/TESTING.md +3 -16
  36. package/docs/USER_TEST_GUIDE.md +39 -13
  37. package/docs/assets/loadout-workflow.png +0 -0
  38. package/docs/evidence/readme-claims.json +0 -8
  39. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
  40. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
  41. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
  42. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
  43. package/package.json +4 -6
  44. package/dashboard/app.js +0 -607
  45. package/dashboard/index.html +0 -249
  46. package/dashboard/styles.css +0 -384
  47. package/dist/src/core/demo.js +0 -136
  48. package/dist/src/dashboard.js +0 -421
@@ -1,5 +1,7 @@
1
1
  import { applyActivationChange, formatActivationPlan, planActivationChange, } from "./active-set.js";
2
- import { scanProject } from "./recommend.js";
2
+ import { detectAgents } from "./paths.js";
3
+ import { formatDetectedSignals, scanProject } from "./recommend.js";
4
+ import { scanInstalledSkills } from "./skill-inventory.js";
3
5
  import { readInstallState } from "./state.js";
4
6
  import { outcomeAdjustment, projectTaskFamilies, readLocalOutcomes, } from "./outcomes.js";
5
7
  const FOUNDATION = new Set([
@@ -17,13 +19,46 @@ const FOUNDATION = new Set([
17
19
  "code-review-excellence",
18
20
  "api-design-principles",
19
21
  "architecture-patterns",
20
- "accessibility-compliance",
21
22
  "deployment-pipeline-design",
22
23
  "documentation-writer",
23
24
  "openai-docs",
24
- "web-design-guidelines",
25
25
  ]);
26
- const SPECIALIZED_WITHOUT_SIGNAL = /(?:appstore|backtesting|bats-|oracle|salesforce|power-bi|dataverse|qdrant|azure-|aws-|mcp-server-generator|sandbox-npm|migration)/;
26
+ const SPECIALIZED_WITHOUT_SIGNAL = /(?:appstore|msstore|backtesting|bats-|oracle|salesforce|power-bi|dataverse|qdrant|azure-|aws-|mcp-server-generator|sandbox-npm|migration|social-(?:publish|media)|marketing|copywriting)/;
27
+ /**
28
+ * A matching word such as `mcp`, `cli`, or `publish` is not enough to make a
29
+ * skill compatible with the detected project. These gates run before scoring
30
+ * so a strong generic signal cannot pull in tooling for another ecosystem.
31
+ * Explicit pins remain an intentional user override.
32
+ */
33
+ function hasProjectCompatibility(name, project) {
34
+ const hasLanguage = (language) => project.languages.includes(language);
35
+ const languageRequirements = [
36
+ [/(?:dotnet|csharp|aspnet|nuget|maui)/, ".net"],
37
+ [
38
+ /(?:python|pytest|fastapi|django|flask|pydantic|poetry|uv-package)/,
39
+ "python",
40
+ ],
41
+ [/(?:^|-)go(?:-|$)|golang/, "go"],
42
+ [/(?:rust|cargo)/, "rust"],
43
+ [/(?:^|-)java(?:-|$)|spring-boot/, "java"],
44
+ [/(?:ruby|rails|bundler)/, "ruby"],
45
+ [/(?:elixir|phoenix)/, "elixir"],
46
+ ];
47
+ for (const [pattern, language] of languageRequirements)
48
+ if (pattern.test(name) && !hasLanguage(language))
49
+ return false;
50
+ if (/(?:nodejs-)?backend|api-server/.test(name) &&
51
+ !project.roles.includes("backend"))
52
+ return false;
53
+ if (/(?:^|-)vercel(?:-|$)/.test(name) &&
54
+ !project.frameworks.includes("next.js") &&
55
+ !project.files.includes("vercel.json"))
56
+ return false;
57
+ if (/(?:publish-to-pages|github-pages|cloudflare-pages)/.test(name) &&
58
+ !project.frameworks.some((framework) => ["react", "next.js", "vue", "svelte"].includes(framework)))
59
+ return false;
60
+ return true;
61
+ }
27
62
  const SOURCE_PRIORITY = {
28
63
  superpowers: 80,
29
64
  context7: 75,
@@ -47,7 +82,7 @@ const GENERAL = [
47
82
  const SIGNAL_RULES = [
48
83
  {
49
84
  signal: (project) => project.languages.includes("javascript/typescript"),
50
- pattern: /(?:javascript|typescript|nodejs|npm|webapp)/,
85
+ pattern: /(?:javascript|typescript|nodejs|webapp)|(?:^|-)npm(?:-|$)/,
51
86
  label: "JavaScript/TypeScript project",
52
87
  },
53
88
  {
@@ -85,7 +120,82 @@ const SIGNAL_RULES = [
85
120
  pattern: /playwright|e2e|webapp-testing/,
86
121
  label: "Playwright project",
87
122
  },
123
+ {
124
+ signal: (project) => project.roles.includes("node-cli"),
125
+ pattern: /^(?:cli|cli-design|cli-mastery|node-cli|command-line|command-line-tools?|building-cli(?:-apps?)?)$/,
126
+ label: "Node CLI",
127
+ },
128
+ {
129
+ signal: (project) => project.roles.includes("npm-package"),
130
+ pattern: /(?:^|-)npm(?:-|$)|package|publish/,
131
+ label: "npm package",
132
+ },
133
+ {
134
+ signal: (project) => project.roles.includes("release"),
135
+ pattern: /release|publish|package/,
136
+ label: "release automation",
137
+ },
138
+ {
139
+ signal: (project) => project.roles.includes("mcp"),
140
+ pattern: /mcp|model-context-protocol/,
141
+ label: "MCP tooling",
142
+ },
143
+ {
144
+ signal: (project) => project.roles.includes("security"),
145
+ pattern: /security|threat|secrets|owasp/,
146
+ label: "security policy",
147
+ },
148
+ {
149
+ signal: (project) => project.roles.includes("obsidian-vault"),
150
+ pattern: /obsidian|json-canvas|defuddle/,
151
+ label: "Obsidian vault",
152
+ },
153
+ {
154
+ signal: (project) => project.tools.includes("vitest"),
155
+ pattern: /vitest/,
156
+ label: "Vitest",
157
+ },
158
+ {
159
+ signal: (project) => project.tools.includes("commander"),
160
+ pattern: /^(?:cli|cli-design|cli-mastery|node-cli|commander|command-line|command-line-tools?|building-cli(?:-apps?)?)$/,
161
+ label: "Commander CLI",
162
+ },
163
+ {
164
+ signal: (project) => project.tools.includes("zod"),
165
+ pattern: /zod|schema-validation|(?:^|-)validation(?:-|$)/,
166
+ label: "Zod schemas",
167
+ },
88
168
  ];
169
+ const FAMILY_CAPS = {
170
+ "browser-testing": 3,
171
+ documentation: 2,
172
+ "code-review": 2,
173
+ architecture: 2,
174
+ planning: 3,
175
+ security: 3,
176
+ "language-tooling": 5,
177
+ testing: 3,
178
+ uncategorized: 3,
179
+ };
180
+ function candidateFamily(unitId) {
181
+ if (/playwright|e2e|webapp-testing|browser-testing/.test(unitId))
182
+ return "browser-testing";
183
+ if (/docs|documentation|readme/.test(unitId))
184
+ return "documentation";
185
+ if (/review|refactor/.test(unitId))
186
+ return "code-review";
187
+ if (/architect|api-design|openapi/.test(unitId))
188
+ return "architecture";
189
+ if (/brainstorm|planning|writing-plans/.test(unitId))
190
+ return "planning";
191
+ if (/security|threat|secrets|owasp/.test(unitId))
192
+ return "security";
193
+ if (/test|coverage|vitest|jest/.test(unitId))
194
+ return "testing";
195
+ if (/javascript|typescript|nodejs|npm|package|cli|zod|schema/.test(unitId))
196
+ return "language-tooling";
197
+ return "uncategorized";
198
+ }
89
199
  function selector(record) {
90
200
  if (!record.unitId)
91
201
  return undefined;
@@ -96,6 +206,10 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
96
206
  if (!id || !record.unitId)
97
207
  return undefined;
98
208
  const name = record.unitId.toLowerCase();
209
+ if (/(?:^|-)jest(?:-|$)/.test(name) &&
210
+ project.tools.includes("vitest") &&
211
+ !project.tools.includes("jest"))
212
+ return undefined;
99
213
  let score = 0;
100
214
  const reasons = [];
101
215
  if (pinned.has(id) || pinned.has(record.unitId)) {
@@ -103,6 +217,8 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
103
217
  reasons.push("explicitly pinned");
104
218
  }
105
219
  const explicitlyPinned = score >= 1000;
220
+ if (!explicitlyPinned && !hasProjectCompatibility(name, project))
221
+ return undefined;
106
222
  if (!explicitlyPinned && SPECIALIZED_WITHOUT_SIGNAL.test(name))
107
223
  return undefined;
108
224
  if (FOUNDATION.has(name)) {
@@ -136,25 +252,8 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
136
252
  reasons: [...new Set(reasons)],
137
253
  };
138
254
  }
139
- /** Deterministic, local-only active-set selection over the reviewed library. */
140
- export async function planProjectActivation(projectPath, options = {}) {
141
- const limit = options.limit ?? 40;
142
- if (!Number.isInteger(limit) || limit < 1 || limit > 200)
143
- throw new Error("--limit must be an integer from 1 to 200");
144
- const project = await scanProject(projectPath);
145
- const state = await readInstallState();
146
- const outcomes = await readLocalOutcomes();
147
- const taskFamilies = projectTaskFamilies(project);
148
- const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
149
- (!options.agents || options.agents.includes(record.agent)));
150
- const byAgent = new Map();
151
- for (const record of relevant)
152
- if (record.activationState === "active")
153
- byAgent.set(record.agent, (byAgent.get(record.agent) ?? 0) + 1);
154
- const activeBefore = Math.max(0, ...byAgent.values());
155
- const capacity = Math.max(0, limit - activeBefore);
156
- const pins = new Set(options.pins ?? []);
157
- const scored = relevant
255
+ function rankedCandidates(records, project, pins, outcomes, taskFamilies) {
256
+ const scored = records
158
257
  .filter((record) => record.activationState === "disabled" &&
159
258
  record.cacheState === "downloaded" &&
160
259
  record.reviewState === "reviewed")
@@ -165,19 +264,24 @@ export async function planProjectActivation(projectPath, options = {}) {
165
264
  (SOURCE_PRIORITY[left.packageId] ?? 0) ||
166
265
  left.unitId.localeCompare(right.unitId) ||
167
266
  left.packageId.localeCompare(right.packageId));
168
- // One capability name wins by evidence order; overlapping collections remain
169
- // in the library and can still be explicitly pinned by full selector.
170
267
  const unique = [];
171
268
  const claimedUnits = new Set();
269
+ const familyCounts = new Map();
172
270
  for (const item of scored) {
173
271
  const fullPin = pins.has(item.selector);
174
272
  if (!fullPin && claimedUnits.has(item.unitId))
175
273
  continue;
274
+ const family = candidateFamily(item.unitId);
275
+ if (!fullPin && (familyCounts.get(family) ?? 0) >= FAMILY_CAPS[family])
276
+ continue;
176
277
  claimedUnits.add(item.unitId);
278
+ familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
177
279
  unique.push(item);
178
280
  }
179
- const selected = unique.slice(0, capacity);
180
- const alternatives = selected.flatMap((chosen) => {
281
+ return unique;
282
+ }
283
+ function alternativesFor(selected, scored) {
284
+ return selected.flatMap((chosen) => {
181
285
  const deferred = scored
182
286
  .filter((candidate) => candidate.unitId === chosen.unitId &&
183
287
  candidate.selector !== chosen.selector)
@@ -186,7 +290,67 @@ export async function planProjectActivation(projectPath, options = {}) {
186
290
  ? [{ unitId: chosen.unitId, selected: chosen.selector, deferred }]
187
291
  : [];
188
292
  });
293
+ }
294
+ /** Deterministic, local-only active-set selection over the reviewed library. */
295
+ export async function planProjectActivation(projectPath, options = {}) {
296
+ const limit = options.limit ?? 40;
297
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
298
+ throw new Error("--limit must be an integer from 1 to 200");
299
+ const project = await scanProject(projectPath);
300
+ const state = await readInstallState();
301
+ const outcomes = await readLocalOutcomes();
302
+ const taskFamilies = projectTaskFamilies(project);
303
+ const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
304
+ (!options.agents || options.agents.includes(record.agent)));
305
+ const requestedAgents = options.agents ?? [
306
+ ...new Set(relevant.map((record) => record.agent)),
307
+ ];
308
+ const detected = (await detectAgents()).filter((agent) => requestedAgents.includes(agent.id));
309
+ const inventory = await scanInstalledSkills(detected);
310
+ const pins = new Set(options.pins ?? []);
189
311
  const warnings = [];
312
+ const agentPlans = requestedAgents.map((agent) => {
313
+ const summary = inventory.agents.find((item) => item.agent === agent);
314
+ if (!summary)
315
+ throw new Error(`Could not scan active skills for requested agent '${agent}'`);
316
+ const capacity = Math.max(0, limit - summary.total);
317
+ const ranked = rankedCandidates(relevant.filter((record) => record.agent === agent), project, pins, outcomes, taskFamilies);
318
+ const selected = ranked.slice(0, capacity);
319
+ if (!capacity)
320
+ warnings.push(`${summary.displayName} has reached the active-set limit ${limit}; disable skills or raise --limit.`);
321
+ return {
322
+ agent,
323
+ displayName: summary.displayName,
324
+ activeBefore: summary.total,
325
+ managedBefore: summary.managed,
326
+ unmanagedBefore: summary.unmanaged,
327
+ capacity,
328
+ selected,
329
+ alternatives: alternativesFor(selected, ranked),
330
+ };
331
+ });
332
+ const activationPlans = await Promise.all(agentPlans
333
+ .filter((agentPlan) => agentPlan.selected.length)
334
+ .map((agentPlan) => planActivationChange("enable", agentPlan.selected.map((item) => item.selector), { agents: [agentPlan.agent] })));
335
+ const activation = activationPlans.length
336
+ ? {
337
+ action: "enable",
338
+ packages: [
339
+ ...new Set(activationPlans.flatMap((plan) => plan.packages)),
340
+ ],
341
+ requestedAgents,
342
+ changes: activationPlans.flatMap((plan) => plan.changes),
343
+ skipped: activationPlans.flatMap((plan) => plan.skipped),
344
+ blocked: activationPlans.some((plan) => plan.blocked),
345
+ warnings: activationPlans.flatMap((plan) => plan.warnings),
346
+ }
347
+ : undefined;
348
+ const selected = [
349
+ ...new Map(agentPlans
350
+ .flatMap((agentPlan) => agentPlan.selected)
351
+ .map((item) => [item.selector, item])).values(),
352
+ ];
353
+ const alternatives = agentPlans.flatMap((item) => item.alternatives);
190
354
  const known = new Set(relevant.flatMap((record) => {
191
355
  const id = selector(record);
192
356
  return id ? [id, record.unitId] : [];
@@ -194,17 +358,13 @@ export async function planProjectActivation(projectPath, options = {}) {
194
358
  const unknownPins = [...pins].filter((pin) => !known.has(pin));
195
359
  if (unknownPins.length)
196
360
  warnings.push(`Pinned skill(s) are not in the managed library: ${unknownPins.join(", ")}`);
197
- if (!capacity)
198
- warnings.push(`Active-set limit ${limit} is already reached; disable skills or raise --limit.`);
199
- const activation = selected.length
200
- ? await planActivationChange("enable", selected.map((item) => item.selector), { ...(options.agents ? { agents: options.agents } : {}) })
201
- : undefined;
202
361
  return {
203
362
  project,
204
363
  limit,
205
364
  ...(options.agents ? { agents: options.agents } : {}),
206
- activeBefore,
207
- capacity,
365
+ agentPlans,
366
+ activeBefore: Math.max(0, ...agentPlans.map((item) => item.activeBefore)),
367
+ capacity: Math.min(limit, ...agentPlans.map((item) => item.capacity)),
208
368
  selected,
209
369
  alternatives,
210
370
  ...(activation ? { activation } : {}),
@@ -214,17 +374,31 @@ export async function planProjectActivation(projectPath, options = {}) {
214
374
  export async function applyProjectActivation(plan) {
215
375
  if (!plan.activation || !plan.selected.length)
216
376
  throw new Error("No reviewed library skills were selected for activation");
217
- return applyActivationChange(plan.activation);
377
+ return applyActivationChange(plan.activation, {
378
+ preflight: async () => {
379
+ const requested = plan.agentPlans.map((item) => item.agent);
380
+ const detected = (await detectAgents()).filter((agent) => requested.includes(agent.id));
381
+ const inventory = await scanInstalledSkills(detected);
382
+ for (const agentPlan of plan.agentPlans) {
383
+ const current = inventory.agents.find((item) => item.agent === agentPlan.agent);
384
+ if (!current)
385
+ throw new Error(`Could not re-scan active skills for requested agent '${agentPlan.agent}'`);
386
+ if (current.total + agentPlan.selected.length > plan.limit)
387
+ throw new Error(`${agentPlan.displayName} active skill capacity changed after preview: ${current.total} active plus ${agentPlan.selected.length} proposed exceeds limit ${plan.limit}`);
388
+ }
389
+ },
390
+ });
218
391
  }
219
392
  export function formatProjectActivation(plan) {
220
- const detected = [...plan.project.languages, ...plan.project.frameworks];
221
393
  return [
222
394
  `Project: ${plan.project.root}`,
223
- `Detected: ${detected.join(", ") || "no known project signals"}`,
224
- `Active-set budget: ${plan.activeBefore}/${plan.limit} active; ${plan.capacity} slot(s) available`,
225
- `Proposed additions: ${plan.selected.length}`,
226
- ...plan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
227
- ...plan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
395
+ `Detected: ${formatDetectedSignals(plan.project) || "no known project signals"}`,
396
+ ...plan.agentPlans.flatMap((agentPlan) => [
397
+ `${agentPlan.displayName}: ${agentPlan.activeBefore} active (${agentPlan.managedBefore} managed, ${agentPlan.unmanagedBefore} unmanaged); ${agentPlan.capacity}/${plan.limit} slots available`,
398
+ `Proposed additions for ${agentPlan.displayName}: ${agentPlan.selected.length}`,
399
+ ...agentPlan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
400
+ ...agentPlan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
401
+ ]),
228
402
  ...(plan.activation
229
403
  ? ["", "Exact activation delta:", formatActivationPlan(plan.activation)]
230
404
  : []),
@@ -3,6 +3,7 @@ import { cp, lstat, mkdir, readFile, readdir, rm } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { agentSkillsDirectory, ensureDirectory } from "./paths.js";
5
5
  import { activationLibraryPath, installStatePath, readInstallState, writeInstallState, } from "./state.js";
6
+ import { inspectTargetOccupancy } from "./target-occupancy.js";
6
7
  import { runMutationTransaction } from "./transaction.js";
7
8
  /** Resolve a managed file to its active location or disabled library copy. */
8
9
  export function managedFileReadPath(packageId, filePath, activations) {
@@ -262,9 +263,12 @@ export async function planActivationChange(action, packageIds, options = {}) {
262
263
  const libraryActual = await hashTree(record.libraryPath);
263
264
  change.blockers.push(...exactTreeDifferences(record.libraryFiles, libraryActual).map((message) => `library copy: ${message}`));
264
265
  }
265
- const occupied = (await Promise.all(change.targets.map((target) => pathExists(target.activePath)))).filter(Boolean).length;
266
- if (occupied)
267
- change.blockers.push(`${occupied} active target(s) already exist and would be overwritten`);
266
+ const occupied = (await Promise.all(change.targets.map(async (target) => ({
267
+ target,
268
+ result: await inspectTargetOccupancy(target.activePath),
269
+ })))).filter((item) => item.result.occupied);
270
+ if (occupied.length)
271
+ change.blockers.push(`${occupied.length} active target(s) already contain protected content and would be overwritten; first target: ${occupied[0].target.activePath} (${occupied[0].result.reason})`);
268
272
  }
269
273
  changes.push(change);
270
274
  }
@@ -283,8 +287,9 @@ export async function planActivationChange(action, packageIds, options = {}) {
283
287
  function activationKey(record) {
284
288
  return `${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`;
285
289
  }
286
- export async function applyActivationChange(plan) {
290
+ export async function applyActivationChange(plan, options = {}) {
287
291
  const applied = await runMutationTransaction(async () => {
292
+ await options.preflight?.();
288
293
  const fresh = await planActivationChange(plan.action, plan.packages, {
289
294
  ...(plan.requestedAgents ? { agents: plan.requestedAgents } : {}),
290
295
  });
@@ -337,6 +342,10 @@ export async function applyActivationChange(plan) {
337
342
  else {
338
343
  for (const target of change.targets) {
339
344
  const source = join(change.libraryPath, target.libraryRelativePath);
345
+ const occupancy = await inspectTargetOccupancy(target.activePath);
346
+ if (occupancy.occupied)
347
+ throw new Error(`${change.packageId}/${change.agent}: activation target became occupied: ${target.activePath} (${occupancy.reason})`);
348
+ await rm(target.activePath, { recursive: true, force: true });
340
349
  await mkdir(dirname(target.activePath), { recursive: true });
341
350
  await cp(source, target.activePath, {
342
351
  recursive: true,
@@ -358,7 +367,7 @@ export async function applyActivationChange(plan) {
358
367
  }
359
368
  state.activations = records.map((record) => replacements.get(activationKey(record)) ?? record);
360
369
  await writeInstallState(state);
361
- });
370
+ }, { label: `${plan.action} project skill set` });
362
371
  return applied.snapshotId;
363
372
  }
364
373
  function formatLibraryHeadline(report) {
@@ -208,7 +208,7 @@ export async function applySkillAdoption(plan, options = {}) {
208
208
  throw new Error("The skill changed after preview; scan again before adopting it.");
209
209
  },
210
210
  });
211
- });
211
+ }, { label: `adopt ${plan.packageId}` });
212
212
  return applied.snapshotId;
213
213
  }
214
214
  export function formatAdoptionPlan(plan) {
@@ -77,7 +77,8 @@ export function formatCatalogCoverage(report) {
77
77
  .join(", ");
78
78
  return [
79
79
  `Screened catalog: ${report.technicallyScreenedRecords}/${report.records} technically complete records (target ${report.targetRecords})`,
80
- `Policy selection: ${report.recommendedRecords} Stable sources · ${report.trustStages["human-reviewed"]} human-reviewed · ${report.trustStages.benchmarked} benchmarked`,
80
+ `Catalog maturity: ${report.records} sourced · ${report.technicallyScreenedRecords} technically inspected · ${report.recommendedRecords} selected for Stable`,
81
+ `Higher-confidence evidence: ${report.trustStages["human-reviewed"]} stored human-review attestations · ${report.trustStages.benchmarked} signed benchmark results`,
81
82
  `Evidence: ${report.immutablePins} immutable pins · ${report.assertedLicenses} asserted licenses · ${report.noAssertionLicenses} NOASSERTION`,
82
83
  `Coverage: ${Object.values(report.categories).length} categories · ${report.evaluationReady} evaluation-ready · ${report.activityObserved} with refreshed activity`,
83
84
  `Install shape: ${report.installShapes.skills} skill · ${report.installShapes.mcpOnly} MCP-only · ${report.installShapes.mixed} mixed`,
@@ -123,14 +123,17 @@ export async function prepareCatalogInstall(selection, options = {}) {
123
123
  catch (error) {
124
124
  completed += 1;
125
125
  const reason = error instanceof Error ? error.message : String(error);
126
+ const fullyQuarantined = rejected.size > 0 && /No SKILL\.md found/.test(reason);
126
127
  options.onProgress?.({
127
128
  packageId: pkg.id,
128
129
  completed,
129
130
  total: installable.length,
130
131
  status: "skipped",
131
- message: `${pkg.displayName} could not be prepared: ${reason}`,
132
+ message: fullyQuarantined
133
+ ? `${pkg.displayName}: all ${rejected.size} discovered skill unit(s) were quarantined`
134
+ : `${pkg.displayName} could not be prepared: ${reason}`,
132
135
  });
133
- if (rejected.size > 0 && /No SKILL\.md found/.test(reason))
136
+ if (fullyQuarantined)
134
137
  return { quarantined: [...rejected.values()] };
135
138
  return {
136
139
  result: {
@@ -195,7 +198,7 @@ export async function prepareCatalogInstall(selection, options = {}) {
195
198
  reconciliation,
196
199
  };
197
200
  }
198
- export function formatPreparedCatalogInstall(prepared) {
201
+ export function formatPreparedCatalogInstall(prepared, options = {}) {
199
202
  const targetDirectories = prepared.entries.reduce((total, entry) => total + entry.plan.files.length, 0);
200
203
  const directoriesPerAgent = prepared.agents.length
201
204
  ? Math.ceil(targetDirectories / prepared.agents.length)
@@ -215,11 +218,13 @@ export function formatPreparedCatalogInstall(prepared) {
215
218
  "Automatic skill setup does not require an OpenAI, Anthropic, or OpenRouter API key; credentialed MCP/runtime integrations remain explicit and deferred.",
216
219
  ];
217
220
  if (directoriesPerAgent > RECOMMENDED_ACTIVE_SKILL_LIMIT)
218
- lines.push(`Capacity warning: about ${directoriesPerAgent} skill directories per agent exceeds the recommended active-set limit of ${RECOMMENDED_ACTIVE_SKILL_LIMIT}.${prepared.selection.mode === "maximum" ? " Maximum stores these in the disabled library; use project activation to choose the working set." : " Prefer Stable or project-aware activation for smaller context."}`);
221
+ lines.push(`Capacity notice: about ${directoriesPerAgent} skill directories per agent exceeds Stable's ${RECOMMENDED_ACTIVE_SKILL_LIMIT}-skill bound.${prepared.selection.mode === "maximum" ? " Maximum stores them in the disabled library; optimize or activate a project-relevant working set." : prepared.selection.mode === "power" ? " Power is the explicit larger active mode; choose Stable or project optimization when lower context use matters." : " Use project-aware activation for a smaller working set."}`);
219
222
  if (failures.length)
220
223
  lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
221
224
  if (quarantined.length)
222
225
  lines.push(`Quarantined invalid skill units: ${quarantined.length} (safe siblings remain available)`);
226
+ if (explicit.length)
227
+ lines.push(`Deferred integration setup: ${explicit.length} record(s) remain opt-in through MCP/runtime commands.`);
223
228
  if (prepared.collisions.length)
224
229
  lines.push(`Overlapping skill targets resolved: ${prepared.collisions.length} lower-ranked duplicate directories deferred`);
225
230
  if (retired.length) {
@@ -228,11 +233,19 @@ export function formatPreparedCatalogInstall(prepared) {
228
233
  lines.push(`Retire ${item.packageId}${item.unitId ? `/${item.unitId}` : ""} from ${item.agent}`);
229
234
  }
230
235
  if (risky.length)
231
- lines.push(`Additional risk approval required: ${risky.map((entry) => entry.package.id).join(", ")}`);
236
+ lines.push(options.details
237
+ ? `Additional risk approval required: ${risky.map((entry) => entry.package.id).join(", ")}`
238
+ : `Additional risk approval required for ${risky.length} repositor${risky.length === 1 ? "y" : "ies"}. Run the preview with --details before approval.`);
232
239
  for (const warning of prepared.resolution.warnings)
233
240
  lines.push(`Warning: ${warning}`);
234
- for (const item of prepared.skipped)
235
- lines.push(`${item.kind === "preparation-failed" ? "Failed" : item.kind === "quarantined" ? "Quarantined" : "Deferred"} ${item.packageId}${item.unitId ? `/${item.unitId}` : ""}: ${item.reason}`);
241
+ if (failures.length)
242
+ for (const item of failures)
243
+ lines.push(`Failed ${item.packageId}${item.unitId ? `/${item.unitId}` : ""}: ${item.reason}`);
244
+ if (options.details)
245
+ for (const item of prepared.skipped.filter((item) => item.kind !== "preparation-failed"))
246
+ lines.push(`${item.kind === "quarantined" ? "Quarantined" : "Deferred"} ${item.packageId}${item.unitId ? `/${item.unitId}` : ""}: ${item.reason}`);
247
+ else if (quarantined.length || explicit.length)
248
+ lines.push("Use --details to show every quarantined or deferred unit.");
236
249
  return lines.join("\n");
237
250
  }
238
251
  export async function applyPreparedCatalogInstall(prepared, options = {}) {
@@ -258,5 +271,7 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
258
271
  });
259
272
  }
260
273
  export function formatCatalogApplyGuidance(riskApprovalRequired) {
261
- return `Preview complete; nothing was changed. Re-run with --yes${riskApprovalRequired ? " --approve-risk" : ""} to install this exact screened plan.`;
274
+ return riskApprovalRequired
275
+ ? "Preview complete; nothing was changed. Inspect the plan with --details, then re-run with --yes --approve-risk only if you accept every reported finding."
276
+ : "Preview complete; nothing was changed. Re-run with --yes to install this exact screened plan.";
262
277
  }
@@ -7,6 +7,7 @@ START HERE
7
7
 
8
8
  2. Preview the recommended everyday setup
9
9
  loadout setup --mode stable
10
+ loadout profiles
10
11
 
11
12
  3. Find additions that fit the project in this folder
12
13
  loadout recommend --project .
@@ -17,8 +18,10 @@ START HERE
17
18
  loadout alerts
18
19
  loadout candidate list --limit 10
19
20
 
20
- 5. If you want a visual view, start the private local dashboard
21
- loadout dashboard
21
+ 5. See reviewed MCP integrations and their credential needs
22
+ loadout mcp-recipe
23
+ loadout mcp-recipe --credential-free
24
+ loadout mcp-recipe playwright --agent codex
22
25
 
23
26
  If you decide to install something, Loadout shows a preview first and creates a
24
27
  snapshot before changing managed files. Recover with: loadout rollback
@@ -16,6 +16,49 @@ function tomlString(value) {
16
16
  function tableHeader(name) {
17
17
  return `[mcp_servers.${tomlString(name)}]`;
18
18
  }
19
+ function tablePrefix(name) {
20
+ return `[mcp_servers.${tomlString(name)}`;
21
+ }
22
+ export function codexMcpServerBlock(content, name) {
23
+ safeName(name);
24
+ const lines = content.match(/.*(?:\n|$)/g)?.filter(Boolean) ?? [];
25
+ const header = tableHeader(name);
26
+ const start = lines.findIndex((line) => line.trim() === header);
27
+ if (start < 0)
28
+ return undefined;
29
+ const prefix = tablePrefix(name);
30
+ let end = start + 1;
31
+ while (end < lines.length) {
32
+ const trimmed = lines[end].trim();
33
+ if (trimmed.startsWith("[") && !trimmed.startsWith(`${prefix}.`))
34
+ break;
35
+ end += 1;
36
+ }
37
+ return lines.slice(start, end).join("");
38
+ }
39
+ export function codexMcpServerFingerprint(content, name) {
40
+ const block = codexMcpServerBlock(content, name);
41
+ return block ? createHash("sha256").update(block).digest("hex") : undefined;
42
+ }
43
+ export function removeCodexMcpServerBlock(content, name) {
44
+ const block = codexMcpServerBlock(content, name);
45
+ if (!block)
46
+ return content;
47
+ return content.replace(block, "").replace(/\n{3,}/g, "\n\n");
48
+ }
49
+ export async function writeCodexMcpConfigContent(path, content) {
50
+ const target = resolve(path);
51
+ await mkdir(dirname(target), { recursive: true });
52
+ const temporary = join(dirname(target), `.${basename(target)}.loadout-${randomUUID()}.tmp`);
53
+ try {
54
+ await writeFile(temporary, content, { mode: 0o600 });
55
+ await rename(temporary, target);
56
+ }
57
+ catch (error) {
58
+ await rm(temporary, { force: true });
59
+ throw error;
60
+ }
61
+ }
19
62
  export function defaultCodexMcpConfigPath() {
20
63
  return join(userHome(), ".codex", "config.toml");
21
64
  }
@@ -73,7 +116,7 @@ export async function planCodexMcpConfig(path, server, name = server.name) {
73
116
  async function snapshotPath(id) {
74
117
  return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
75
118
  }
76
- export async function applyCodexMcpConfigPlan(plan) {
119
+ export async function applyCodexMcpConfigPlan(plan, options = {}) {
77
120
  const applied = await runMutationTransaction(async () => {
78
121
  let content = "";
79
122
  let existed = true;
@@ -100,10 +143,10 @@ export async function applyCodexMcpConfigPlan(plan) {
100
143
  };
101
144
  const stored = await snapshotPath(snapshot.id);
102
145
  return {
103
- targets: [plan.path, stored],
146
+ targets: [plan.path, stored, ...(options.extraTargets ?? [])],
104
147
  value: { plan, snapshot, stored },
105
148
  };
106
- }, async ({ plan: freshPlan, snapshot, stored }) => {
149
+ }, async ({ plan: freshPlan, snapshot, stored }, transactionSnapshot) => {
107
150
  await mkdir(dirname(freshPlan.path), { recursive: true });
108
151
  const temporary = join(dirname(freshPlan.path), `.${basename(freshPlan.path)}.loadout-${randomUUID()}.tmp`);
109
152
  try {
@@ -111,12 +154,13 @@ export async function applyCodexMcpConfigPlan(plan) {
111
154
  await rename(temporary, freshPlan.path);
112
155
  await mkdir(dirname(stored), { recursive: true });
113
156
  await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
157
+ await options.afterApply?.(snapshot, transactionSnapshot);
114
158
  }
115
159
  catch (error) {
116
160
  await rm(temporary, { force: true });
117
161
  throw error;
118
162
  }
119
163
  return snapshot;
120
- });
121
- return applied.result;
164
+ }, { label: `configure Codex MCP ${plan.serverName}` });
165
+ return { ...applied.result, rollbackSnapshotId: applied.snapshotId };
122
166
  }
@@ -16,8 +16,6 @@ const commands = [
16
16
  "add",
17
17
  "unadd",
18
18
  "list",
19
- "demo",
20
- "test-drive",
21
19
  "scan",
22
20
  "status",
23
21
  "versions",
@@ -86,7 +84,6 @@ const commands = [
86
84
  "outcomes",
87
85
  "remove",
88
86
  "uninstall",
89
- "dashboard",
90
87
  "serve",
91
88
  "completion",
92
89
  ];