loadout-ai 0.4.1 → 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 (45) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/MASTER_PLAN.md +106 -33
  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 +48 -8
  8. package/dist/src/core/active-set.js +1 -1
  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 +3 -3
  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 +34 -18
  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/transaction.js +2 -1
  29. package/dist/src/shared/schemas.js +1 -0
  30. package/docs/CATALOG.md +3 -2
  31. package/docs/DISCOVERED.md +253 -255
  32. package/docs/FEATURE_TEST_MATRIX.md +19 -32
  33. package/docs/GITHUB_AUTHORIZATION.md +2 -2
  34. package/docs/TESTING.md +3 -16
  35. package/docs/USER_TEST_GUIDE.md +23 -9
  36. package/docs/assets/loadout-workflow.png +0 -0
  37. package/docs/evidence/readme-claims.json +0 -8
  38. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
  39. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
  40. package/package.json +4 -6
  41. package/dashboard/app.js +0 -607
  42. package/dashboard/index.html +0 -249
  43. package/dashboard/styles.css +0 -384
  44. package/dist/src/core/demo.js +0 -136
  45. package/dist/src/dashboard.js +0 -421
@@ -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
  ];
@@ -4,6 +4,7 @@ import { managedFileReadPath } from "./active-set.js";
4
4
  import { detectAgents } from "./paths.js";
5
5
  import { readInstallState } from "./state.js";
6
6
  import { buildUpdatePlan } from "./update.js";
7
+ import { codexMcpServerFingerprint } from "./codex-mcp.js";
7
8
  async function drift(record, activations) {
8
9
  const changed = [];
9
10
  for (const file of record.files) {
@@ -20,8 +21,10 @@ async function drift(record, activations) {
20
21
  }
21
22
  return changed;
22
23
  }
23
- async function mcpDrift(configPath, serverName, expected) {
24
+ async function mcpDrift(configPath, serverName, expected, configFormat = "json") {
24
25
  try {
26
+ if (configFormat === "codex-toml")
27
+ return (codexMcpServerFingerprint(await readFile(configPath, "utf8"), serverName) !== expected);
25
28
  const config = JSON.parse(await readFile(configPath, "utf8"));
26
29
  return (createHash("sha256")
27
30
  .update(JSON.stringify(config.mcpServers?.[serverName] ?? null))
@@ -42,7 +45,7 @@ export async function buildHealthReport(options = {}) {
42
45
  : Promise.resolve([]),
43
46
  ]);
44
47
  const drifted = (await Promise.all(state.installs.map((record) => drift(record, state.activations ?? [])))).flat();
45
- const driftedMcpServers = (await Promise.all((state.mcpInstalls ?? []).map((entry) => mcpDrift(entry.configPath, entry.serverName, entry.fingerprint)))).filter(Boolean).length;
48
+ const driftedMcpServers = (await Promise.all((state.mcpInstalls ?? []).map((entry) => mcpDrift(entry.configPath, entry.serverName, entry.fingerprint, entry.configFormat)))).filter(Boolean).length;
46
49
  const findings = [];
47
50
  if (!agents.some((agent) => agent.installed))
48
51
  findings.push({
@@ -96,11 +99,14 @@ export async function buildHealthReport(options = {}) {
96
99
  message: `${errors.length} update check(s) could not be completed.`,
97
100
  fix: "Check connectivity and retry.",
98
101
  });
102
+ const configured = state.installs.length > 0 || (state.mcpInstalls ?? []).length > 0;
99
103
  const status = findings.some((finding) => finding.level === "error")
100
104
  ? "unhealthy"
101
- : findings.some((finding) => finding.level === "warning")
102
- ? "attention"
103
- : "healthy";
105
+ : !configured
106
+ ? "not-configured"
107
+ : findings.some((finding) => finding.level === "warning")
108
+ ? "attention"
109
+ : "healthy";
104
110
  return {
105
111
  status,
106
112
  generatedAt: new Date().toISOString(),
@@ -114,13 +120,15 @@ export async function buildHealthReport(options = {}) {
114
120
  };
115
121
  }
116
122
  export function formatHealthReport(report) {
117
- const icon = report.status === "healthy"
118
- ? "✓"
119
- : report.status === "attention"
120
- ? "!"
121
- : "✗";
123
+ const icon = report.status === "not-configured"
124
+ ? "•"
125
+ : report.status === "healthy"
126
+ ? "✓"
127
+ : report.status === "attention"
128
+ ? "!"
129
+ : "✗";
122
130
  const lines = [
123
- `${icon} Loadout health: ${report.status}`,
131
+ `${icon} Loadout health: ${report.status === "not-configured" ? "not configured" : report.status}`,
124
132
  `Packages: ${report.installedPackages} installed, ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}, ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
125
133
  ];
126
134
  for (const finding of report.findings)
@@ -209,7 +209,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
209
209
  }
210
210
  await recordInstall(freshPlan, snapshot.id, metadata);
211
211
  await options.verifyBeforeCommit?.(snapshot.id);
212
- });
212
+ }, { label: `install ${plan.packageId}` });
213
213
  return applied.snapshotId;
214
214
  }
215
215
  /** Apply all selected packages as one filesystem transaction and one state update. */
@@ -278,7 +278,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
278
278
  await recordInstallBatch(freshEntries, snapshot.id);
279
279
  await recordManagedProfileReconciliation(reconciliation);
280
280
  await options.afterRecord?.();
281
- });
281
+ }, { label: `install ${entries.length} skill repositories` });
282
282
  return applied.snapshotId;
283
283
  }
284
284
  /**
@@ -343,7 +343,7 @@ export async function applySkillLibraryBatch(entries, options = {}) {
343
343
  }
344
344
  await recordLibraryInstallBatch(freshEntries, snapshot.id);
345
345
  await options.afterRecord?.();
346
- });
346
+ }, { label: `download ${entries.length} repositories to Maximum library` });
347
347
  return applied.snapshotId;
348
348
  }
349
349
  export function snapshotPath(snapshotId, environment = process.env, platform = process.platform) {
@@ -93,8 +93,7 @@ function recipeServer(recipe, sourcePath, credentialEnvironment = {}) {
93
93
  warnings: [],
94
94
  };
95
95
  }
96
- export async function planMcpRecipe(recipeId, configPath, options = {}) {
97
- const recipe = findMcpRecipe(recipeId);
96
+ function credentialEnvironmentForRecipe(recipeId, recipe, options) {
98
97
  const environment = options.environment ?? process.env;
99
98
  const credentialEnvironment = {};
100
99
  for (const name of Object.keys(options.credentialReferences ?? {}))
@@ -114,13 +113,23 @@ export async function planMcpRecipe(recipeId, configPath, options = {}) {
114
113
  }
115
114
  if (!/^[A-Z_][A-Z0-9_]*$/.test(reference.name))
116
115
  throw new Error(`Invalid environment reference for ${name}; use an uppercase variable name, never a credential value.`);
117
- if (!reference.name || !environment[reference.name]) {
116
+ if (!environment[reference.name]) {
118
117
  if (options.requireResolvedCredentials)
119
- throw new Error(`Environment credential reference '${reference.name || "(empty)"}' for ${name} did not resolve.`);
118
+ throw new Error(`Environment credential reference '${reference.name}' for ${name} did not resolve.`);
120
119
  continue;
121
120
  }
122
121
  credentialEnvironment[name] = reference.name;
123
122
  }
123
+ return credentialEnvironment;
124
+ }
125
+ /** Build the exact reviewed server entry without writing or launching it. */
126
+ export function buildMcpRecipeServer(recipeId, sourcePath, options = {}) {
127
+ const recipe = findMcpRecipe(recipeId);
128
+ return recipeServer(recipe, sourcePath, credentialEnvironmentForRecipe(recipeId, recipe, options));
129
+ }
130
+ export async function planMcpRecipe(recipeId, configPath, options = {}) {
131
+ const recipe = findMcpRecipe(recipeId);
132
+ const credentialEnvironment = credentialEnvironmentForRecipe(recipeId, recipe, options);
124
133
  const config = await planMcpConfig(configPath, recipeServer(recipe, recipe.source, credentialEnvironment));
125
134
  return {
126
135
  recipe,
@@ -221,7 +221,7 @@ export function summarizeMcpConfigPlan(plan) {
221
221
  async function snapshotPath(id) {
222
222
  return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
223
223
  }
224
- export async function applyMcpConfigPlan(plan) {
224
+ export async function applyMcpConfigPlan(plan, options = {}) {
225
225
  const applied = await runMutationTransaction(async () => {
226
226
  await mkdir(dirname(plan.path), { recursive: true });
227
227
  let existed = true;
@@ -246,16 +246,17 @@ export async function applyMcpConfigPlan(plan) {
246
246
  };
247
247
  const stored = await snapshotPath(snapshot.id);
248
248
  return {
249
- targets: [plan.path, stored],
249
+ targets: [plan.path, stored, ...(options.extraTargets ?? [])],
250
250
  value: { plan, snapshot, stored },
251
251
  };
252
- }, async ({ plan: freshPlan, snapshot, stored }) => {
252
+ }, async ({ plan: freshPlan, snapshot, stored }, transactionSnapshot) => {
253
253
  await writeMcpConfigPlan(freshPlan);
254
254
  await mkdir(dirname(stored), { recursive: true });
255
255
  await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
256
+ await options.afterApply?.(snapshot, transactionSnapshot);
256
257
  return snapshot;
257
- });
258
- return applied.result;
258
+ }, { label: `configure MCP ${plan.serverName}` });
259
+ return { ...applied.result, rollbackSnapshotId: applied.snapshotId };
259
260
  }
260
261
  /** Write an already-reviewed plan atomically; transaction callers own snapshot/rollback. */
261
262
  export async function writeMcpConfigPlan(plan) {
@@ -57,7 +57,7 @@ export async function applyProviderModelSelection(plan) {
57
57
  }, async (configuration) => {
58
58
  await ensureDirectory(dirname(plan.path));
59
59
  await writeFileAtomically(plan.path, `${JSON.stringify(configuration, null, 2)}\n`);
60
- });
60
+ }, { label: `configure model ${plan.selection.id}` });
61
61
  return applied.snapshotId;
62
62
  }
63
63
  export function formatProviderModelConfiguration(configuration) {
@@ -125,6 +125,6 @@ export async function applyPortableImport(source, manifestPath = "loadout.json",
125
125
  await Promise.all(temporary.map((path) => rm(path, { force: true })));
126
126
  }
127
127
  return plan;
128
- });
128
+ }, { label: "import portable loadout" });
129
129
  return { plan: applied.result, snapshotId: applied.snapshotId };
130
130
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import { basename, resolve } from "node:path";
3
- import { STABLE_BOOST_PACKAGE_IDS } from "./profiles.js";
3
+ import { POWER_SKILL_ALLOWLIST, STABLE_BOOST_PACKAGE_IDS } from "./profiles.js";
4
4
  import { packageOutcomeAdjustment, projectTaskFamilies, } from "./outcomes.js";
5
5
  const SIGNAL_FILES = new Set([
6
6
  "package.json",
@@ -8,6 +8,7 @@ const SIGNAL_FILES = new Set([
8
8
  "requirements.txt",
9
9
  "go.mod",
10
10
  "Cargo.toml",
11
+ "mix.exs",
11
12
  "Gemfile",
12
13
  "pom.xml",
13
14
  "build.gradle",
@@ -15,8 +16,10 @@ const SIGNAL_FILES = new Set([
15
16
  "next.config.mjs",
16
17
  "vite.config.ts",
17
18
  "playwright.config.ts",
19
+ "vercel.json",
18
20
  "SECURITY.md",
19
21
  ".git",
22
+ ".obsidian",
20
23
  ]);
21
24
  /** Additive machine-readable boundary for every rule-selected recommendation list. */
22
25
  export const RECOMMENDATION_BOUNDARY = Object.freeze({
@@ -31,6 +34,7 @@ const SIGNAL_LABELS = {
31
34
  release: "release automation",
32
35
  mcp: "MCP tooling",
33
36
  security: "security policy",
37
+ "obsidian-vault": "Obsidian vault",
34
38
  commander: "Commander",
35
39
  zod: "Zod",
36
40
  vitest: "Vitest",
@@ -44,6 +48,7 @@ const SIGNAL_DISPLAY_ORDER = [
44
48
  "release",
45
49
  "mcp",
46
50
  "security",
51
+ "obsidian-vault",
47
52
  "commander",
48
53
  "zod",
49
54
  "vitest",
@@ -98,6 +103,12 @@ export async function scanProject(root = process.cwd()) {
98
103
  tools.add("commander");
99
104
  if (deps.zod)
100
105
  tools.add("zod");
106
+ if (deps.express ||
107
+ deps.fastify ||
108
+ deps.koa ||
109
+ deps.hono ||
110
+ deps["@nestjs/core"])
111
+ roles.add("backend");
101
112
  if (pkg.bin)
102
113
  roles.add("node-cli");
103
114
  if (pkg.publishConfig || pkg.private === false)
@@ -119,6 +130,8 @@ export async function scanProject(root = process.cwd()) {
119
130
  languages.add("go");
120
131
  if (files.includes("Cargo.toml"))
121
132
  languages.add("rust");
133
+ if (files.includes("mix.exs"))
134
+ languages.add("elixir");
122
135
  if (files.includes("Gemfile"))
123
136
  languages.add("ruby");
124
137
  if (files.includes("pom.xml") || files.includes("build.gradle"))
@@ -133,6 +146,8 @@ export async function scanProject(root = process.cwd()) {
133
146
  }
134
147
  if (files.includes("SECURITY.md"))
135
148
  roles.add("security");
149
+ if (files.includes(".obsidian"))
150
+ roles.add("obsidian-vault");
136
151
  return {
137
152
  root: absolute,
138
153
  languages: [...languages],
@@ -168,6 +183,8 @@ export function recommendPackages(signals, catalog) {
168
183
  add("playwright-mcp", "Browser verification is useful for this web-capable project.", signals.frameworks.includes("playwright") ? "high" : "medium");
169
184
  if (signals.files.includes(".git"))
170
185
  add("github-mcp-server", "A Git repository was detected; GitHub tools may help with issues and pull requests.", "medium");
186
+ if (signals.roles.includes("obsidian-vault"))
187
+ add("obsidian-skills", "An Obsidian vault was detected; these reviewed skills cover its open Markdown, Bases, and JSON Canvas formats.", "high");
171
188
  return result;
172
189
  }
173
190
  function confidenceRank(confidence) {
@@ -215,34 +232,33 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
215
232
  }
216
233
  export const TESTED_PROFILES = {
217
234
  stable: {
218
- description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
235
+ description: "30 active skills from four pinned sources: the bounded everyday starting point.",
219
236
  packages: [...STABLE_BOOST_PACKAGE_IDS],
220
237
  },
221
- web: {
222
- description: "Planning, documentation, interface guidance, and browser verification for web projects.",
223
- packages: ["superpowers", "context7", "ui-ux-pro-max", "playwright-mcp"],
224
- },
225
- collaboration: {
226
- description: "Engineering workflow plus GitHub collaboration tools.",
227
- packages: ["superpowers", "context7", "github-mcp-server"],
238
+ power: {
239
+ description: "A deliberately larger active toolkit selected from eight pinned skill collections; expect higher agent context use.",
240
+ packages: Object.keys(POWER_SKILL_ALLOWLIST),
228
241
  },
229
242
  maximum: {
230
- description: "Broad inspected toolkit; always review MCP permissions before applying.",
231
- packages: [
232
- "superpowers",
233
- "context7",
234
- "playwright-mcp",
235
- "ui-ux-pro-max",
236
- "github-mcp-server",
237
- ],
243
+ description: "The broadest screened catalog: skills enter the disabled library and MCP/runtime integrations remain explicit.",
244
+ packages: [],
245
+ },
246
+ custom: {
247
+ description: "Only the package IDs you explicitly pass to setup or upgrade.",
248
+ packages: [],
238
249
  },
239
250
  };
240
251
  export function profileManifestPackages(profile, catalog) {
241
252
  const selected = TESTED_PROFILES[profile];
242
253
  if (!selected)
243
254
  throw new Error(`Unknown profile '${profile}'. Choose: ${Object.keys(TESTED_PROFILES).join(", ")}`);
255
+ if (profile === "custom")
256
+ throw new Error("Custom profiles require explicit package IDs; use setup --mode custom --package <id>");
244
257
  const byId = new Map(catalog.map((pkg) => [pkg.id, pkg]));
245
- return selected.packages.map((id) => {
258
+ const packageIds = profile === "maximum"
259
+ ? catalog.filter((pkg) => !pkg.archived).map((pkg) => pkg.id)
260
+ : selected.packages;
261
+ return packageIds.map((id) => {
246
262
  const pkg = byId.get(id);
247
263
  if (!pkg)
248
264
  throw new Error(`Profile '${profile}' references missing catalog package '${id}'`);
@@ -3,6 +3,7 @@ import { readFile, rm } from "node:fs/promises";
3
3
  import { forgetInstall, installStatePath, readInstallState } from "./state.js";
4
4
  import { writeMcpConfigPlan } from "./mcp.js";
5
5
  import { runMutationTransaction } from "./transaction.js";
6
+ import { codexMcpServerFingerprint, removeCodexMcpServerBlock, writeCodexMcpConfigContent, } from "./codex-mcp.js";
6
7
  export async function planRemove(packageId) {
7
8
  const state = await readInstallState();
8
9
  const record = state.installs.find((entry) => entry.packageId === packageId);
@@ -28,11 +29,26 @@ export async function planRemove(packageId) {
28
29
  const modified = files.filter((file) => file.status === "modified");
29
30
  const mcpServers = await Promise.all(trackedMcp.map(async (entry) => {
30
31
  try {
32
+ if (entry.configFormat === "codex-toml") {
33
+ const content = await readFile(entry.configPath, "utf8");
34
+ const fingerprint = codexMcpServerFingerprint(content, entry.serverName);
35
+ return {
36
+ configPath: entry.configPath,
37
+ serverName: entry.serverName,
38
+ configFormat: "codex-toml",
39
+ status: !fingerprint
40
+ ? "missing"
41
+ : fingerprint === entry.fingerprint
42
+ ? "unchanged"
43
+ : "modified",
44
+ };
45
+ }
31
46
  const config = JSON.parse(await readFile(entry.configPath, "utf8"));
32
47
  if (!config.mcpServers || !(entry.serverName in config.mcpServers))
33
48
  return {
34
49
  configPath: entry.configPath,
35
50
  serverName: entry.serverName,
51
+ configFormat: "json",
36
52
  status: "missing",
37
53
  };
38
54
  const fingerprint = createHash("sha256")
@@ -41,6 +57,7 @@ export async function planRemove(packageId) {
41
57
  return {
42
58
  configPath: entry.configPath,
43
59
  serverName: entry.serverName,
60
+ configFormat: "json",
44
61
  status: fingerprint === entry.fingerprint
45
62
  ? "unchanged"
46
63
  : "modified",
@@ -50,6 +67,7 @@ export async function planRemove(packageId) {
50
67
  return {
51
68
  configPath: entry.configPath,
52
69
  serverName: entry.serverName,
70
+ configFormat: entry.configFormat ?? "json",
53
71
  status: "missing",
54
72
  };
55
73
  }
@@ -96,11 +114,19 @@ export async function applyRemove(plan, options = {}) {
96
114
  for (const file of existing)
97
115
  await rm(file, { force: true });
98
116
  for (const configPath of configPaths) {
117
+ const relevant = fresh.mcpServers.filter((entry) => entry.configPath === configPath && entry.status !== "missing");
118
+ if (relevant.some((entry) => entry.configFormat === "codex-toml")) {
119
+ let content = await readFile(configPath, "utf8");
120
+ for (const entry of relevant)
121
+ content = removeCodexMcpServerBlock(content, entry.serverName);
122
+ await writeCodexMcpConfigContent(configPath, content);
123
+ continue;
124
+ }
99
125
  const current = JSON.parse(await readFile(configPath, "utf8"));
100
126
  const servers = {
101
127
  ...(current.mcpServers ?? {}),
102
128
  };
103
- const removals = fresh.mcpServers.filter((entry) => entry.configPath === configPath && entry.status !== "missing");
129
+ const removals = relevant;
104
130
  for (const entry of removals)
105
131
  delete servers[entry.serverName];
106
132
  await writeMcpConfigPlan({
@@ -116,6 +142,6 @@ export async function applyRemove(plan, options = {}) {
116
142
  });
117
143
  }
118
144
  await forgetInstall(plan.packageId);
119
- });
145
+ }, { label: `remove ${plan.packageId}` });
120
146
  return applied.snapshotId;
121
147
  }
@@ -205,11 +205,11 @@ export const runtimeToolRecipeSchema = z
205
205
  .strict(),
206
206
  snapshotRoots: z
207
207
  .array(z.enum(["{runtimeRoot}", "{agentTargets}"]))
208
- .refine((values) => values.includes("{runtimeRoot}") && values.includes("{agentTargets}"), "runtime and agent targets must both be snapshotted"),
208
+ .refine((values) => values.includes("{agentTargets}"), "agent targets must be snapshotted"),
209
209
  removal: z
210
210
  .object({
211
211
  strategy: z.literal("restore-preinstall-snapshot"),
212
- runtimeRoot: z.literal("restore-preinstall-state"),
212
+ runtimeRoot: z.literal("remove-managed-runtime"),
213
213
  })
214
214
  .strict(),
215
215
  generatedFiles: generatedFilesSchema,
@@ -147,10 +147,10 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
147
147
  "kiro-cli": { path: [".kiro", "skills", "graphify"] },
148
148
  },
149
149
  timeouts: { commandMs: 300_000 },
150
- snapshotRoots: ["{runtimeRoot}", "{agentTargets}"],
150
+ snapshotRoots: ["{agentTargets}"],
151
151
  removal: {
152
152
  strategy: "restore-preinstall-snapshot",
153
- runtimeRoot: "restore-preinstall-state",
153
+ runtimeRoot: "remove-managed-runtime",
154
154
  },
155
155
  generatedFiles: {
156
156
  requiredRelativePaths: ["SKILL.md"],
@@ -402,12 +402,24 @@ export async function applyRuntimeToolPlan(plan, options) {
402
402
  await restoreSnapshot(snapshot, {
403
403
  requireUnchangedPostMutationState: true,
404
404
  });
405
+ await rm(installed.runtimeRoot, { recursive: true, force: true });
405
406
  delete state.tools[plan.recipe.id];
406
407
  await writeState(state, plan.stateHome);
407
408
  return { action: "remove", snapshotId: snapshot.id };
408
409
  }
409
410
  if (state.tools[plan.recipe.id])
410
411
  throw new Error(`${plan.recipe.displayName} is already managed by Loadout`);
412
+ try {
413
+ await access(plan.runtimeRoot);
414
+ throw new Error(`${plan.recipe.displayName} runtime path already exists outside managed state: ${plan.runtimeRoot}`);
415
+ }
416
+ catch (error) {
417
+ if (!error ||
418
+ typeof error !== "object" ||
419
+ !("code" in error) ||
420
+ error.code !== "ENOENT")
421
+ throw error;
422
+ }
411
423
  let snapshot;
412
424
  try {
413
425
  const snapshotRoots = plan.recipe.snapshotRoots.flatMap((root) => root === "{runtimeRoot}"
@@ -453,8 +465,7 @@ export async function applyRuntimeToolPlan(plan, options) {
453
465
  catch (error) {
454
466
  if (snapshot)
455
467
  await restoreSnapshot(snapshot).catch(() => undefined);
456
- else
457
- await rm(plan.runtimeRoot, { recursive: true, force: true });
468
+ await rm(plan.runtimeRoot, { recursive: true, force: true });
458
469
  throw error;
459
470
  }
460
471
  }
@@ -10,6 +10,7 @@ export async function createSnapshot(paths, options = {}) {
10
10
  .digest("hex")
11
11
  .slice(0, 12)}`,
12
12
  createdAt: new Date().toISOString(),
13
+ ...(options.label ? { label: options.label } : {}),
13
14
  roots: normalizedRoots,
14
15
  files: [],
15
16
  };
@@ -154,6 +155,22 @@ export async function listSnapshotIds() {
154
155
  throw error;
155
156
  }
156
157
  }
158
+ function changedEntryCount(before, after) {
159
+ if (!after)
160
+ return undefined;
161
+ const previous = new Map(before.map((file) => [file.path, file]));
162
+ const next = new Map(after.map((file) => [file.path, file]));
163
+ return [...new Set([...previous.keys(), ...next.keys()])].filter((path) => JSON.stringify(previous.get(path)) !== JSON.stringify(next.get(path))).length;
164
+ }
165
+ export function summarizeSnapshot(snapshot) {
166
+ return {
167
+ id: snapshot.id,
168
+ createdAt: snapshot.createdAt,
169
+ label: snapshot.label ?? "managed change",
170
+ roots: snapshot.roots.length,
171
+ changedEntries: changedEntryCount(snapshot.files, snapshot.postMutationFiles),
172
+ };
173
+ }
157
174
  const SNAPSHOT_ID = /^\d{10,}-[a-f0-9]{12}$/;
158
175
  function isSnapshotId(value) {
159
176
  return SNAPSHOT_ID.test(value);
@@ -186,6 +203,9 @@ export function validateSnapshot(value) {
186
203
  if (typeof value.createdAt !== "string" ||
187
204
  !Number.isFinite(Date.parse(value.createdAt)))
188
205
  throw new Error("Snapshot creation time is invalid");
206
+ if (value.label !== undefined &&
207
+ (typeof value.label !== "string" || value.label.trim().length === 0))
208
+ throw new Error("Snapshot label is invalid");
189
209
  if (!Array.isArray(value.roots) ||
190
210
  value.roots.length === 0 ||
191
211
  !value.roots.every((root) => typeof root === "string" && root.length > 0))
@@ -302,6 +302,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
302
302
  await writeInstallState(state);
303
303
  return { installs, mcpInstalls };
304
304
  }
305
+ /** Record one externally formatted but Loadout-managed MCP entry. */
306
+ export async function recordMcpInstall(record) {
307
+ const state = await readInstallState();
308
+ state.mcpInstalls = [
309
+ ...(state.mcpInstalls ?? []).filter((entry) => entry.packageId !== record.packageId),
310
+ record,
311
+ ];
312
+ await writeInstallState(state);
313
+ }
305
314
  export async function forgetInstall(packageId) {
306
315
  const state = await readInstallState();
307
316
  const installs = state.installs.filter((entry) => entry.packageId !== packageId);
@@ -343,6 +343,6 @@ export async function applySyncPlan(plan, lockPath = "loadout.lock", options = {
343
343
  await writeMcpConfigPlan(entry.plan);
344
344
  await recordInstallTransaction(freshPlan.packages, freshPlan.mcpPlans, snapshot.id);
345
345
  await writeLockfile(freshPlan.resolvedManifest ?? (await readManifest(freshPlan.manifest)), lockPath);
346
- });
346
+ }, { label: "synchronize loadout manifest" });
347
347
  return { snapshotId: applied.snapshotId, lockfile: lockPath };
348
348
  }
@@ -123,13 +123,14 @@ export async function withMutationLock(operation) {
123
123
  * global lock. Network/source preparation should happen before this boundary;
124
124
  * every state-dependent precondition belongs in `prepare`.
125
125
  */
126
- export async function runMutationTransaction(prepare, mutate) {
126
+ export async function runMutationTransaction(prepare, mutate, options = {}) {
127
127
  const mutationLock = await acquireMutationLock();
128
128
  let transaction;
129
129
  try {
130
130
  const prepared = await prepare();
131
131
  const snapshot = await createSnapshot(prepared.targets, {
132
132
  persist: false,
133
+ label: options.label,
133
134
  });
134
135
  transaction = await beginTransaction(snapshot, prepared.targets, {
135
136
  mutationLock,
@@ -275,6 +275,7 @@ export const installRecordSchema = z
275
275
  files: z.array(fileHashSchema),
276
276
  snapshotId: text,
277
277
  installedAt: text,
278
+ configFormat: z.enum(["json", "codex-toml"]).optional(),
278
279
  staticAssessment: z
279
280
  .object({
280
281
  status: z.enum(["clear", "warning", "blocking"]),