loadout-ai 0.5.1 → 0.5.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.3 - 2026-07-21
6
+
7
+ ### Fixed
8
+
9
+ - Include reviewed runtime-tool skills installed at official host-specific paths in
10
+ inventory, provenance, capacity, and health reporting. Graphify is now recognized
11
+ as Loadout-managed in both `~/.claude/skills` and Codex's `~/.codex/skills` target.
12
+ - Report managed MCP server and runtime-tool counts in health, and warn when an
13
+ installed runtime tool's expected skill target disappears.
14
+ - Give runtime-tool install snapshots meaningful labels instead of `managed change`.
15
+ - Allow 30 seconds by default for a cold reviewed MCP connection handshake and make
16
+ MCP-only removal preview language accurately describe a removal plan.
17
+
18
+ ## 0.5.2 - 2026-07-21
19
+
20
+ ### Fixed
21
+
22
+ - Revalidate and apply the exact skill-and-agent activation delta shown in a project
23
+ preview instead of broadening package selections across every requested agent.
24
+ - Reject browser-testing skills and Playwright MCP recommendations for CLI-only
25
+ TypeScript projects without a browser or frontend signal.
26
+ - Replace unexplained ranking numbers and a duplicated full activation delta with a
27
+ concise exact-transaction summary in the default project optimization output.
28
+
5
29
  ## 0.5.1 - 2026-07-21
6
30
 
7
31
  ### Fixed
package/MASTER_PLAN.md CHANGED
@@ -277,6 +277,23 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
277
277
  previews an unblocked removal of 29 packages/2,316 disabled records, and health
278
278
  reports `library ready (nothing active)` with explicit counts. Release as
279
279
  `0.5.1` before continuing the complete-uninstall acceptance step.
280
+ - [x] `P18-38 [TERRA]` Fix the founder-discovered asymmetric project-activation
281
+ scope bug. The `0.5.1` preview correctly budgeted 22 Codex and 18 Claude
282
+ additions, but apply-time revalidation broadened four Codex-only selectors
283
+ onto Claude. Revalidation now preserves the exact package, skill, and agent
284
+ tuple from the preview; a two-agent regression proves exact 30/18 applied
285
+ counts. CLI-only TypeScript projects also reject browser-testing and
286
+ Playwright false positives, and the default preview removes unexplained scores
287
+ and duplicated delta output. Release as `0.5.2` before resuming activation.
288
+ - [x] `P18-39 [TERRA]` Integrate reviewed runtime tools into the same inventory and
289
+ health truth as skill repositories. Founder testing proved Graphify installed
290
+ correctly for Claude Code and Codex, but the scanner counted Claude's target as
291
+ unmanaged and omitted Codex's official `~/.codex/skills` target because its
292
+ standard collection root is `~/.agents/skills`. Scan every registered runtime
293
+ target, attribute it to `runtime-tool:<id>`, count both host targets in health,
294
+ surface runtime/MCP totals and missing targets, label runtime snapshots, and
295
+ raise the default cold MCP handshake window to the tested 30 seconds. Release
296
+ as `0.5.3` before continuing Graphify removal and discovery/update acceptance.
280
297
 
281
298
  ### Release 0.3 lifecycle hardening
282
299
 
package/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
  </p>
27
27
 
28
28
  > [!IMPORTANT]
29
- > Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.5.1`; review the preview before every apply.
29
+ > Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.5.3`; review the preview before every apply.
30
30
 
31
31
  ## How it works
32
32
 
@@ -76,7 +76,7 @@ Skills, plugins, MCP servers, and agent settings tend to accumulate one experime
76
76
  You need Node.js 20 or newer and Git.
77
77
 
78
78
  ```bash
79
- npm install --global loadout-ai@0.5.1
79
+ npm install --global loadout-ai@0.5.3
80
80
  loadout --version
81
81
  loadout guide
82
82
  ```
package/dist/src/cli.js CHANGED
@@ -263,7 +263,7 @@ async function runSetup(options) {
263
263
  reader?.close();
264
264
  }
265
265
  }
266
- const LOADOUT_VERSION = "0.5.1";
266
+ const LOADOUT_VERSION = "0.5.3";
267
267
  function durableSchedulerLauncher() {
268
268
  return [
269
269
  join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
@@ -1007,7 +1007,7 @@ program
1007
1007
  const plan = await planRemove(packageId);
1008
1008
  console.log(JSON.stringify(plan, null, 2));
1009
1009
  if (!options.yes)
1010
- return console.log("Dry run only. Re-run with --yes to remove these files.");
1010
+ return console.log("Dry run only. Re-run with --yes to apply this removal plan.");
1011
1011
  const snapshot = await applyRemove(plan, { force: options.force });
1012
1012
  console.log(`Removed ${packageId}. Snapshot: ${snapshot}`);
1013
1013
  });
@@ -2142,7 +2142,7 @@ program
2142
2142
  .option("--connect", "launch the exact pinned artifact and perform an MCP initialize handshake")
2143
2143
  .option("--credential <mapping>", "credential mapping NAME=env:VARIABLE or NAME=keychain:SERVICE (repeatable)", collectOption, [])
2144
2144
  .option("--credential-account <account>", "account for keychain mappings")
2145
- .option("--timeout <milliseconds>", "real connection timeout", "8000")
2145
+ .option("--timeout <milliseconds>", "real connection timeout", "30000")
2146
2146
  .option("--approve-risk", "approve launching the reviewed pinned MCP artifact for --connect")
2147
2147
  .option("--json", "emit machine-readable JSON")
2148
2148
  .option("--no-key", "list recipes needing no separately billed AI/model API key")
@@ -57,6 +57,10 @@ function hasProjectCompatibility(name, project) {
57
57
  if (/(?:publish-to-pages|github-pages|cloudflare-pages)/.test(name) &&
58
58
  !project.frameworks.some((framework) => ["react", "next.js", "vue", "svelte"].includes(framework)))
59
59
  return false;
60
+ if (/playwright|e2e|webapp-testing|browser-testing/.test(name) &&
61
+ !project.frameworks.includes("playwright") &&
62
+ !project.frameworks.some((framework) => ["react", "next.js", "vue", "svelte"].includes(framework)))
63
+ return false;
60
64
  return true;
61
65
  }
62
66
  const SOURCE_PRIORITY = {
@@ -396,11 +400,20 @@ export function formatProjectActivation(plan) {
396
400
  ...plan.agentPlans.flatMap((agentPlan) => [
397
401
  `${agentPlan.displayName}: ${agentPlan.activeBefore} active (${agentPlan.managedBefore} managed, ${agentPlan.unmanagedBefore} unmanaged); ${agentPlan.capacity}/${plan.limit} slots available`,
398
402
  `Proposed additions for ${agentPlan.displayName}: ${agentPlan.selected.length}`,
399
- ...agentPlan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
403
+ ...agentPlan.selected.map((item) => ` + ${item.selector} — ${item.reasons.join(", ")}`),
400
404
  ...agentPlan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
401
405
  ]),
402
406
  ...(plan.activation
403
- ? ["", "Exact activation delta:", formatActivationPlan(plan.activation)]
407
+ ? plan.activation.blocked
408
+ ? [
409
+ "",
410
+ "Blocked activation details:",
411
+ formatActivationPlan(plan.activation),
412
+ ]
413
+ : [
414
+ "",
415
+ `Activation transaction: ${plan.activation.changes.length} exact skill change(s), applied together with one rollback snapshot.`,
416
+ ]
404
417
  : []),
405
418
  ...plan.warnings.map((warning) => `Warning: ${warning}`),
406
419
  ].join("\n");
@@ -287,12 +287,39 @@ export async function planActivationChange(action, packageIds, options = {}) {
287
287
  function activationKey(record) {
288
288
  return `${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`;
289
289
  }
290
+ async function revalidateExactActivationPlan(plan) {
291
+ const selectorsByAgent = new Map();
292
+ for (const change of plan.changes) {
293
+ const selectors = selectorsByAgent.get(change.agent) ?? [];
294
+ selectors.push(change.unitId ? `${change.packageId}/${change.unitId}` : change.packageId);
295
+ selectorsByAgent.set(change.agent, selectors);
296
+ }
297
+ const fragments = await Promise.all([...selectorsByAgent].map(([agent, selectors]) => planActivationChange(plan.action, selectors, { agents: [agent] })));
298
+ const changes = fragments.flatMap((fragment) => fragment.changes);
299
+ const plannedKeys = plan.changes.map(activationKey);
300
+ const freshKeys = changes.map(activationKey);
301
+ if (new Set(plannedKeys).size !== plannedKeys.length ||
302
+ new Set(freshKeys).size !== freshKeys.length ||
303
+ plannedKeys.length !== freshKeys.length ||
304
+ plannedKeys.some((key) => !freshKeys.includes(key)))
305
+ throw new Error("Activation selection changed after preview; generate a new plan before applying.");
306
+ const order = new Map(plannedKeys.map((key, index) => [key, index]));
307
+ changes.sort((left, right) => order.get(activationKey(left)) - order.get(activationKey(right)));
308
+ const warnings = fragments.flatMap((fragment) => fragment.warnings);
309
+ return {
310
+ action: plan.action,
311
+ packages: plan.packages,
312
+ ...(plan.requestedAgents ? { requestedAgents: plan.requestedAgents } : {}),
313
+ changes,
314
+ skipped: fragments.flatMap((fragment) => fragment.skipped),
315
+ blocked: warnings.length > 0,
316
+ warnings,
317
+ };
318
+ }
290
319
  export async function applyActivationChange(plan, options = {}) {
291
320
  const applied = await runMutationTransaction(async () => {
292
321
  await options.preflight?.();
293
- const fresh = await planActivationChange(plan.action, plan.packages, {
294
- ...(plan.requestedAgents ? { agents: plan.requestedAgents } : {}),
295
- });
322
+ const fresh = await revalidateExactActivationPlan(plan);
296
323
  if (fresh.blocked)
297
324
  throw new Error(fresh.warnings.join("; "));
298
325
  if (!fresh.changes.length)
@@ -1,10 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
3
4
  import { managedFileReadPath } from "./active-set.js";
4
5
  import { detectAgents } from "./paths.js";
5
6
  import { readInstallState } from "./state.js";
6
7
  import { buildUpdatePlan } from "./update.js";
7
8
  import { codexMcpServerFingerprint } from "./codex-mcp.js";
9
+ import { listInstalledRuntimeToolSkillTargets, listInstalledRuntimeTools, } from "./runtime-tools.js";
8
10
  async function drift(record, activations) {
9
11
  const changed = [];
10
12
  for (const file of record.files) {
@@ -35,18 +37,30 @@ async function mcpDrift(configPath, serverName, expected, configFormat = "json")
35
37
  }
36
38
  }
37
39
  export async function buildHealthReport(options = {}) {
38
- const [agents, state, updates] = await Promise.all([
39
- detectAgents(),
40
+ const [agents, state, updates, runtimeTools, runtimeTargets] = await Promise.all([
41
+ options.agents ? options.agents() : detectAgents(),
40
42
  readInstallState(),
41
43
  options.updates
42
44
  ? options.updates()
43
45
  : options.checkUpdates
44
46
  ? buildUpdatePlan()
45
47
  : Promise.resolve([]),
48
+ listInstalledRuntimeTools(),
49
+ listInstalledRuntimeToolSkillTargets(),
46
50
  ]);
47
51
  const drifted = (await Promise.all(state.installs.map((record) => drift(record, state.activations ?? [])))).flat();
48
52
  const driftedMcpServers = (await Promise.all((state.mcpInstalls ?? []).map((entry) => mcpDrift(entry.configPath, entry.serverName, entry.fingerprint, entry.configFormat)))).filter(Boolean).length;
49
53
  const findings = [];
54
+ const runtimeTargetPresence = await Promise.all(runtimeTargets.map(async (target) => {
55
+ try {
56
+ await readFile(join(target.path, "SKILL.md"));
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }));
63
+ const missingRuntimeTargets = runtimeTargetPresence.filter((present) => !present).length;
50
64
  if (!agents.some((agent) => agent.installed))
51
65
  findings.push({
52
66
  level: "error",
@@ -81,6 +95,13 @@ export async function buildHealthReport(options = {}) {
81
95
  message: `${driftedMcpServers} managed MCP server entry or entries changed or disappeared outside Loadout.`,
82
96
  fix: "Review the MCP config, then synchronize or remove the owning package.",
83
97
  });
98
+ if (missingRuntimeTargets)
99
+ findings.push({
100
+ level: "warning",
101
+ code: "managed-runtime-target-drift",
102
+ message: `${missingRuntimeTargets} managed runtime-tool skill target(s) changed or disappeared.`,
103
+ fix: "Review the target, then remove and reinstall the owning runtime tool.",
104
+ });
84
105
  const available = updates.filter((update) => update.status === "update-available");
85
106
  if (available.length)
86
107
  findings.push({
@@ -99,9 +120,11 @@ export async function buildHealthReport(options = {}) {
99
120
  message: `${errors.length} update check(s) could not be completed.`,
100
121
  fix: "Check connectivity and retry.",
101
122
  });
102
- const configured = state.installs.length > 0 || (state.mcpInstalls ?? []).length > 0;
123
+ const configured = state.installs.length > 0 ||
124
+ (state.mcpInstalls ?? []).length > 0 ||
125
+ runtimeTools.length > 0;
103
126
  const activeSkills = (state.activations ?? []).filter((entry) => entry.installationState === "installed" &&
104
- entry.activationState === "active").length;
127
+ entry.activationState === "active").length + runtimeTargetPresence.filter(Boolean).length;
105
128
  const disabledSkills = (state.activations ?? []).filter((entry) => entry.installationState === "installed" &&
106
129
  entry.activationState === "disabled").length;
107
130
  const status = findings.some((finding) => finding.level === "error")
@@ -110,7 +133,10 @@ export async function buildHealthReport(options = {}) {
110
133
  ? "not-configured"
111
134
  : findings.some((finding) => finding.level === "warning")
112
135
  ? "attention"
113
- : activeSkills === 0 && disabledSkills > 0
136
+ : activeSkills === 0 &&
137
+ disabledSkills > 0 &&
138
+ (state.mcpInstalls ?? []).length === 0 &&
139
+ runtimeTools.length === 0
114
140
  ? "library-only"
115
141
  : "healthy";
116
142
  return {
@@ -120,6 +146,8 @@ export async function buildHealthReport(options = {}) {
120
146
  installedPackages: state.installs.length,
121
147
  activeSkills,
122
148
  disabledSkills,
149
+ managedMcpServers: (state.mcpInstalls ?? []).length,
150
+ managedRuntimeTools: runtimeTools.length,
123
151
  updatesChecked: Boolean(options.updates || options.checkUpdates),
124
152
  updatesAvailable: available.length,
125
153
  driftedFiles: drifted.length,
@@ -137,7 +165,7 @@ export function formatHealthReport(report) {
137
165
  : "✗";
138
166
  const lines = [
139
167
  `${icon} Loadout health: ${report.status === "not-configured" ? "not configured" : report.status === "library-only" ? "library ready (nothing active)" : report.status}`,
140
- `Packages: ${report.installedPackages} managed; skills: ${report.activeSkills ?? 0} active, ${report.disabledSkills ?? 0} disabled; ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
168
+ `Packages: ${report.installedPackages} managed; skills: ${report.activeSkills ?? 0} active, ${report.disabledSkills ?? 0} disabled; MCP servers: ${report.managedMcpServers ?? 0}; runtime tools: ${report.managedRuntimeTools ?? 0}; ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
141
169
  ];
142
170
  for (const finding of report.findings)
143
171
  lines.push(`${finding.level === "ok" ? "✓" : finding.level === "error" ? "✗" : finding.level === "warning" ? "!" : "•"} ${finding.message}${finding.fix ? ` ${finding.fix}` : ""}`);
@@ -148,7 +148,7 @@ export async function planMcpRecipe(recipeId, configPath, options = {}) {
148
148
  };
149
149
  }
150
150
  const MCP_PROTOCOL_VERSION = "2025-06-18";
151
- const DEFAULT_CONNECTION_TIMEOUT_MS = 8_000;
151
+ const DEFAULT_CONNECTION_TIMEOUT_MS = 30_000;
152
152
  const DEFAULT_CONNECTION_OUTPUT_BYTES = 256 * 1024;
153
153
  const INITIALIZE_ID = "loadout-initialize";
154
154
  function validateConnectionBounds(timeoutMs, maxOutputBytes) {
@@ -179,8 +179,10 @@ export function recommendPackages(signals, catalog) {
179
179
  if (signals.frameworks.some((item) => ["react", "next.js", "vue", "svelte"].includes(item)))
180
180
  add("ui-ux-pro-max", `Frontend framework detected: ${signals.frameworks.join(", ")}.`, "high");
181
181
  if (signals.frameworks.includes("playwright") ||
182
- signals.languages.includes("javascript/typescript"))
183
- add("playwright-mcp", "Browser verification is useful for this web-capable project.", signals.frameworks.includes("playwright") ? "high" : "medium");
182
+ signals.frameworks.some((item) => ["react", "next.js", "vue", "svelte"].includes(item)))
183
+ add("playwright-mcp", signals.frameworks.includes("playwright")
184
+ ? "Playwright is already configured in this project."
185
+ : "Browser verification may help test the detected frontend framework.", signals.frameworks.includes("playwright") ? "high" : "medium");
184
186
  if (signals.files.includes(".git"))
185
187
  add("github-mcp-server", "A Git repository was detected; GitHub tools may help with issues and pull requests.", "medium");
186
188
  if (signals.roles.includes("obsidian-vault"))
@@ -222,6 +222,28 @@ export async function listInstalledRuntimeTools(stateHome = loadoutHome()) {
222
222
  const state = await readState(stateHome);
223
223
  return Object.keys(state.tools).sort();
224
224
  }
225
+ /** Return agent skill targets owned by installed reviewed runtime tools. */
226
+ export async function listInstalledRuntimeToolSkillTargets(options = {}) {
227
+ const state = await readState(options.stateHome ?? loadoutHome());
228
+ const home = options.home ?? userHome();
229
+ const platform = currentRecipePlatform(options.platform);
230
+ return Object.entries(state.tools).flatMap(([toolId, installed]) => {
231
+ const recipe = findRuntimeToolRecipe(toolId);
232
+ return installed.agents.flatMap((agent) => {
233
+ const target = recipe.targets[agent];
234
+ return target
235
+ ? [
236
+ {
237
+ toolId,
238
+ packageId: `runtime-tool:${toolId}`,
239
+ agent,
240
+ path: resolveRuntimeRecipePath(home, target.path, platform),
241
+ },
242
+ ]
243
+ : [];
244
+ });
245
+ });
246
+ }
225
247
  async function writeState(state, stateHome) {
226
248
  await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
227
249
  }
@@ -231,12 +253,10 @@ export function findRuntimeToolRecipe(id) {
231
253
  throw new Error(`Unknown runtime tool '${id}'. Available: ${REVIEWED_RUNTIME_TOOLS.map((item) => item.id).join(", ")}`);
232
254
  return recipe;
233
255
  }
234
- function currentRecipePlatform() {
235
- if (process.platform === "darwin" ||
236
- process.platform === "linux" ||
237
- process.platform === "win32")
238
- return process.platform;
239
- throw new Error(`Runtime tool recipes do not support ${process.platform}`);
256
+ function currentRecipePlatform(platform = process.platform) {
257
+ if (platform === "darwin" || platform === "linux" || platform === "win32")
258
+ return platform;
259
+ throw new Error(`Runtime tool recipes do not support ${platform}`);
240
260
  }
241
261
  function buildRuntimeToolCommands(recipe, runtimeRoot, agents, platform, action) {
242
262
  if (action === "remove")
@@ -425,7 +445,9 @@ export async function applyRuntimeToolPlan(plan, options) {
425
445
  const snapshotRoots = plan.recipe.snapshotRoots.flatMap((root) => root === "{runtimeRoot}"
426
446
  ? [plan.runtimeRoot]
427
447
  : plan.agents.map((agent) => agent.target));
428
- snapshot = await createSnapshot(snapshotRoots);
448
+ snapshot = await createSnapshot(snapshotRoots, {
449
+ label: `install runtime tool ${plan.recipe.displayName}`,
450
+ });
429
451
  const runner = options.runner ?? defaultRunner;
430
452
  const env = runtimeEnvironment(plan);
431
453
  const healthCheckStart = plan.commands.length - plan.recipe.healthChecks.length;
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { lstat, readFile, readdir } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { readInstallState } from "./state.js";
5
+ import { listInstalledRuntimeToolSkillTargets, } from "./runtime-tools.js";
5
6
  const MAX_SCAN_DEPTH = 6;
6
7
  export const RECOMMENDED_ACTIVE_SKILLS = 30;
7
8
  function cleanFrontmatterValue(value) {
@@ -17,7 +18,10 @@ function isInside(root, candidate) {
17
18
  const path = relative(resolve(root), resolve(candidate));
18
19
  return path === "" || (!path.startsWith("..") && !path.startsWith(sep));
19
20
  }
20
- function owningPackage(skillRoot, agent, records, activations) {
21
+ function owningPackage(skillRoot, agent, records, activations, runtimeTargets) {
22
+ const runtime = runtimeTargets.find((target) => target.agent === agent && isInside(target.path, skillRoot));
23
+ if (runtime)
24
+ return runtime.packageId;
21
25
  const active = activations.find((record) => record.agent === agent &&
22
26
  record.activationState === "active" &&
23
27
  record.installationState === "installed" &&
@@ -29,8 +33,15 @@ function owningPackage(skillRoot, agent, records, activations) {
29
33
  activation.agent === agent) &&
30
34
  record.files.some((file) => isInside(skillRoot, file.path)))?.packageId;
31
35
  }
32
- async function scanAgentSkills(agent, records, activations) {
33
- const root = resolve(agent.skillsDirectory);
36
+ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
37
+ const primaryRoot = resolve(agent.skillsDirectory);
38
+ const roots = [
39
+ primaryRoot,
40
+ ...runtimeTargets
41
+ .filter((target) => target.agent === agent.id)
42
+ .map((target) => resolve(target.path))
43
+ .filter((target) => !isInside(primaryRoot, target)),
44
+ ];
34
45
  const skills = [];
35
46
  const warnings = [];
36
47
  async function visit(directory, depth) {
@@ -60,7 +71,7 @@ async function scanAgentSkills(agent, records, activations) {
60
71
  try {
61
72
  const path = join(directory, "SKILL.md");
62
73
  const content = await readFile(path, "utf8");
63
- const packageId = owningPackage(directory, agent.id, records, activations);
74
+ const packageId = owningPackage(directory, agent.id, records, activations, runtimeTargets);
64
75
  const sourceHints = [
65
76
  ...new Set([
66
77
  ...content.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
@@ -102,7 +113,8 @@ async function scanAgentSkills(agent, records, activations) {
102
113
  await visit(join(directory, child.name), depth + 1);
103
114
  }
104
115
  }
105
- await visit(root, 0);
116
+ for (const root of [...new Set(roots)])
117
+ await visit(root, 0);
106
118
  return {
107
119
  skills: skills.sort((left, right) => left.path.localeCompare(right.path)),
108
120
  warnings,
@@ -141,8 +153,11 @@ function duplicateGroups(skills) {
141
153
  * safety verdict and no files are changed by this scan.
142
154
  */
143
155
  export async function scanInstalledSkills(agents) {
144
- const state = await readInstallState();
145
- const scans = await Promise.all(agents.map((agent) => scanAgentSkills(agent, state.installs, state.activations ?? [])));
156
+ const [state, runtimeTargets] = await Promise.all([
157
+ readInstallState(),
158
+ listInstalledRuntimeToolSkillTargets(),
159
+ ]);
160
+ const scans = await Promise.all(agents.map((agent) => scanAgentSkills(agent, state.installs, state.activations ?? [], runtimeTargets)));
146
161
  const skills = scans.flatMap((scan) => scan.skills);
147
162
  const warnings = scans.flatMap((scan) => scan.warnings);
148
163
  const summaries = agents.map((agent, index) => {
@@ -157,6 +172,7 @@ export async function scanInstalledSkills(agents) {
157
172
  managed,
158
173
  unmanaged: entries.length - managed,
159
174
  overRecommendedLimit: entries.length > RECOMMENDED_ACTIVE_SKILLS,
175
+ runtimeToolTargets: runtimeTargets.filter((target) => target.agent === agent.id).length,
160
176
  };
161
177
  });
162
178
  for (const summary of summaries.filter((item) => item.overRecommendedLimit))
@@ -185,7 +201,7 @@ export function formatInstalledSkillInventory(report) {
185
201
  "",
186
202
  ];
187
203
  for (const agent of report.agents)
188
- lines.push(`${agent.detected ? "✓" : "○"} ${agent.displayName}: ${agent.total} skill(s) (${agent.managed} managed, ${agent.unmanaged} unmanaged) — ${agent.directory}`);
204
+ lines.push(`${agent.detected ? "✓" : "○"} ${agent.displayName}: ${agent.total} skill(s) (${agent.managed} managed, ${agent.unmanaged} unmanaged)${agent.runtimeToolTargets ? `, including ${agent.runtimeToolTargets} runtime-tool skill(s)` : ""} — ${agent.directory}`);
189
205
  for (const warning of report.warnings)
190
206
  lines.push(`! ${warning}`);
191
207
  lines.push("", "Read-only scan complete. Unmanaged content was not changed or judged automatically.");
@@ -183,7 +183,7 @@ cleanup deliberately deletes Loadout's snapshots, so it is the last lifecycle te
183
183
  ## Troubleshooting and recovery
184
184
 
185
185
  - **`loadout` is not found after installation:** confirm `npm install --global
186
- loadout-ai@0.5.1` completed, run `hash -r`, and confirm npm's global binary
186
+ loadout-ai@0.5.3` completed, run `hash -r`, and confirm npm's global binary
187
187
  directory is on `PATH`. For a source checkout, run `npm run build` and `npm link`.
188
188
  - **A preview asks for `--approve-risk`:** read the reported scripts, domains,
189
189
  credentials, binaries, or instruction findings. If you accept that specific plan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadout-ai",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",