loadout-ai 0.5.3 → 0.5.5

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,54 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.5 - 2026-07-21
6
+
7
+ ### Changed
8
+
9
+ - Summarize update checks as active, disabled-library, current, and unavailable
10
+ records; hide repetitive current-package lines by default while retaining complete
11
+ JSON output and focused per-package review.
12
+ - Keep disabled Maximum-library copies pinned and explicitly state that newer
13
+ upstream commits neither change nor reactivate agent-visible skills.
14
+ - Treat catalog sources represented by adopted units as present in saved-profile
15
+ evaluation, and describe fully quarantined or unavailable profile sources without
16
+ calling them missing upgrades.
17
+
18
+ ### Fixed
19
+
20
+ - Deduplicate per-agent disabled update state and fetch a shared repository only once
21
+ when several adopted skill units track the same upstream source.
22
+ - Distinguish active updates from disabled-library updates in health output, including
23
+ separately reported network-check failures.
24
+ - Label partial catalog indexing accurately as sources containing quarantined units
25
+ instead of repository failures.
26
+
27
+ ## 0.5.4 - 2026-07-21
28
+
29
+ ### Added
30
+
31
+ - Add `loadout reconcile` to compare complete existing skill trees with pinned
32
+ catalog units, group identical cross-agent mirrors, adopt exact matches without
33
+ rewriting them, and preview unambiguous outdated replacements separately.
34
+ - Add immutable, licensed catalog records for the official Cloudflare Skills
35
+ collection and Humanizer, with direct upstream credit links.
36
+
37
+ ### Changed
38
+
39
+ - Index safe skill siblings even when another unit in the same collection is
40
+ quarantined; repository metadata such as `.git`, caches, and `node_modules` is
41
+ excluded from the distributable security tree.
42
+ - Scan both Codex's canonical `~/.agents/skills` directory and its current
43
+ `~/.codex/skills` compatibility directory while excluding host-bundled `.system`
44
+ skills.
45
+
46
+ ### Fixed
47
+
48
+ - Preserve the exact existing host path and exact adopted skill unit during later
49
+ repository updates instead of broadening a collection or moving a Codex skill.
50
+ - Verify every outdated reconciliation copy byte-for-byte before ownership state is
51
+ committed, with one rollback snapshot for the grouped transaction.
52
+
5
53
  ## 0.5.3 - 2026-07-21
6
54
 
7
55
  ### Fixed
package/MASTER_PLAN.md CHANGED
@@ -294,6 +294,40 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
294
294
  surface runtime/MCP totals and missing targets, label runtime snapshots, and
295
295
  raise the default cold MCP handshake window to the tested 30 seconds. Release
296
296
  as `0.5.3` before continuing Graphify removal and discovery/update acceptance.
297
+ - [x] `P18-40 [TERRA]` Scan both Codex's canonical and current compatibility skill
298
+ roots, exclude host-bundled `.system` entries, and count runtime-tool targets
299
+ once. The real founder preview now reports 47 visible skills: 13 Claude, 12
300
+ Codex, 11 Cursor, and 11 Windsurf.
301
+ - [x] `P18-41 [TERRA]` Credit and pin the official Apache-2.0 Cloudflare Skills
302
+ collection and MIT Humanizer repository as catalog sources. Index safe siblings
303
+ independently so one quarantined unit cannot hide every valid skill in a large
304
+ collection.
305
+ - [x] `P18-42 [TERRA]` Add grouped existing-skill reconciliation. Exact complete-tree
306
+ matches can be adopted without rewriting bytes; Git origins disambiguate
307
+ same-name candidates; outdated replacement remains explicit, risk-gated,
308
+ byte-verified, and one rollback-safe transaction.
309
+ - [x] `P18-43 [TERRA]` Preserve an adopted skill's exact unit and existing host path
310
+ during future updates, including Codex compatibility-root installations. The
311
+ real read-only preview found eight exact groups, two unambiguous old groups,
312
+ and left two quarantined/unknown groups untouched. The complete local release
313
+ gate passes 114 test files with 616 passing tests and one intentional skip,
314
+ both CLI product journeys, package smoke, and a 1,000-skill scan at 1,422.7 ms
315
+ p95 across seven real CLI runs.
316
+ - [ ] `P18-44 [HUMAN+TERRA]` Publish and install `loadout-ai@0.5.4`, run
317
+ `loadout reconcile --refresh`, adopt the eight exact groups, inspect the
318
+ Humanizer and Turnstile update findings before any replacement, then finish
319
+ read-only update/discovery, Graphify removal, complete uninstall, and clean
320
+ reinstall acceptance.
321
+ - [x] `P18-45 [TERRA]` Correct the founder-discovered update-reporting defects after
322
+ the real `0.5.4` Maximum/reconciliation run. Update planning now deduplicates
323
+ repeated agent names and shared-repository fetches, separates active updates
324
+ from newer commits held for disabled library copies, compacts safety output,
325
+ recognizes adopted sources in profile state, and labels partial quarantine
326
+ evidence instead of calling it a repository failure. The real profile confirms
327
+ 39 active managed skills, 2,326 disabled copies, one Graphify runtime, zero
328
+ managed drift, and no active adopted-skill update. Release this correction
329
+ before the remaining Graphify removal, discovery, uninstall, and reinstall
330
+ acceptance steps.
297
331
 
298
332
  ### Release 0.3 lifecycle hardening
299
333
 
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.3`; 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.5`; 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.3
79
+ npm install --global loadout-ai@0.5.5
80
80
  loadout --version
81
81
  loadout guide
82
82
  ```
@@ -117,6 +117,29 @@ loadout rollback
117
117
 
118
118
  Stable currently selects 30 skill directories from four pinned, SPDX-identified, policy-selected public sources. Selection policy is evidence, not a claim that the sources are safe, trusted, human-reviewed, benchmarked, or the right choice for every user.
119
119
 
120
+ ## Manage skills you already have
121
+
122
+ Loadout can compare existing Claude, Codex, Cursor, and other detected-agent skills
123
+ with complete skill trees from pinned catalog sources:
124
+
125
+ ```bash
126
+ # Read-only inventory and source/update comparison
127
+ loadout scan
128
+ loadout reconcile --refresh
129
+
130
+ # Record ownership only for exact byte-for-byte matches; files are not rewritten
131
+ loadout reconcile --yes
132
+
133
+ # Preview old copies that have one unambiguous reviewed source
134
+ loadout reconcile --replace-outdated
135
+ ```
136
+
137
+ Unknown or ambiguous copies remain untouched. Replacing an outdated tree is a
138
+ separate `--replace-outdated --yes` transaction, may require `--approve-risk`, and
139
+ creates one rollback snapshot. Once adopted or replaced, `loadout update` tracks the
140
+ exact upstream repository and skill unit while preserving the agent path already in
141
+ use.
142
+
120
143
  ## Profiles
121
144
 
122
145
  | Profile | Scope |
@@ -170,13 +193,13 @@ tools always remain an explicit choice.
170
193
 
171
194
  <!-- loadout:catalog-coverage:start -->
172
195
 
173
- The bundled catalog currently contains **51 credited public repositories** across **38 categories**: **32 have skill components** and **19 are MCP-only**. All 51 are technically screened and pinned; 4 sources are selected by the bounded Stable policy. See every linked source, license status, component type, and pinned commit in **[Catalog and upstream credits](./docs/CATALOG.md)**.
196
+ The bundled catalog currently contains **53 credited public repositories** across **39 categories**: **34 have skill components** and **19 are MCP-only**. All 53 are technically screened and pinned; 4 sources are selected by the bounded Stable policy. See every linked source, license status, component type, and pinned commit in **[Catalog and upstream credits](./docs/CATALOG.md)**.
174
197
 
175
198
  <!-- loadout:catalog-coverage:end -->
176
199
 
177
200
  <!-- loadout:evidence-stages:start -->
178
201
 
179
- Catalog maturity: **51 sourced**, **51 technically inspected**, and **4 selected for Stable**. Independent human-review attestations and signed comparative benchmarks are not yet published, so Loadout does not pretend static inspection proves usefulness. The pinned catalog remains usable today, and local outcomes can be recorded to improve later rankings. Definitions and promotion rules are in the [catalog policy](./docs/CATALOG_POLICY.md).
202
+ Catalog maturity: **53 sourced**, **53 technically inspected**, and **4 selected for Stable**. Independent human-review attestations and signed comparative benchmarks are not yet published, so Loadout does not pretend static inspection proves usefulness. The pinned catalog remains usable today, and local outcomes can be recorded to improve later rankings. Definitions and promotion rules are in the [catalog policy](./docs/CATALOG_POLICY.md).
180
203
 
181
204
  <!-- loadout:evidence-stages:end -->
182
205
 
@@ -232,6 +255,7 @@ Configured paths and disposable filesystem lifecycle tests do not prove that nat
232
255
  | Guided read-only path | `loadout guide` |
233
256
  | Preview or apply a profile | `loadout setup --mode stable [--yes]` |
234
257
  | List and inspect managed state | `loadout list`; `loadout health --explain` |
258
+ | Compare/manage existing skills | `loadout reconcile [--refresh]` |
235
259
  | Browse or search | `loadout catalog`; `loadout search <words>` |
236
260
  | Recommend for a project | `loadout recommend --project .` |
237
261
  | Preview or apply updates | `loadout update [--yes]` |
@@ -274,7 +298,7 @@ The repository's mixed README product-flow test uses an isolated build, disposab
274
298
 
275
299
  Keep changes scoped, add regression coverage for behavior changes, and run `npm run verify:full`. Report vulnerabilities through [SECURITY.md](./SECURITY.md), without credentials, private source, or unredacted state. General bugs and proposals belong in the [issue tracker](https://github.com/VirajMishra1/loadout/issues).
276
300
 
277
- The catalog contains 51 credited public repositories. Inclusion records discovery and attribution; it does not transfer ownership, imply endorsement, or relicense upstream work.
301
+ The catalog contains 53 credited public repositories. Inclusion records discovery and attribution; it does not transfer ownership, imply endorsement, or relicense upstream work.
278
302
 
279
303
  ## License
280
304
 
@@ -1172,5 +1172,52 @@
1172
1172
  ],
1173
1173
  "verifiedAt": "2026-07-21T08:45:00Z"
1174
1174
  }
1175
+ },
1176
+ {
1177
+ "id": "cloudflare-skills",
1178
+ "displayName": "Cloudflare Skills",
1179
+ "repository": "cloudflare/skills",
1180
+ "description": "Official agent skills for building on Cloudflare, Workers, Agents SDK, Durable Objects, Wrangler, and related platform services.",
1181
+ "category": "cloud-platform",
1182
+ "tier": "official",
1183
+ "license": "Apache-2.0",
1184
+ "components": ["skill", "plugin", "mcp"],
1185
+ "operatingSystems": ["windows", "macos", "linux"],
1186
+ "stars": 2443,
1187
+ "pushedAt": "2026-07-13T22:00:33Z",
1188
+ "source": {
1189
+ "type": "github",
1190
+ "url": "https://github.com/cloudflare/skills",
1191
+ "defaultBranch": "main",
1192
+ "commit": "70215303d44a81a0db3219428f4825b604fc6061",
1193
+ "evidencePaths": [
1194
+ "skills/cloudflare/SKILL.md",
1195
+ "skills/agents-sdk/SKILL.md",
1196
+ ".claude-plugin/plugin.json",
1197
+ ".mcp.json"
1198
+ ],
1199
+ "verifiedAt": "2026-07-21T13:38:00Z"
1200
+ }
1201
+ },
1202
+ {
1203
+ "id": "humanizer",
1204
+ "displayName": "Humanizer",
1205
+ "repository": "blader/humanizer",
1206
+ "description": "A writing skill that identifies common AI-generated prose patterns and rewrites text in a more natural voice.",
1207
+ "category": "writing",
1208
+ "tier": "community",
1209
+ "license": "MIT",
1210
+ "components": ["skill"],
1211
+ "operatingSystems": ["windows", "macos", "linux"],
1212
+ "stars": 30153,
1213
+ "pushedAt": "2026-06-29T20:43:06Z",
1214
+ "source": {
1215
+ "type": "github",
1216
+ "url": "https://github.com/blader/humanizer",
1217
+ "defaultBranch": "main",
1218
+ "commit": "1b48564898e999219882660237fde01bf4843a0f",
1219
+ "evidencePaths": ["SKILL.md", "README.md", "LICENSE"],
1220
+ "verifiedAt": "2026-07-21T13:38:00Z"
1221
+ }
1175
1222
  }
1176
1223
  ]
package/dist/src/cli.js CHANGED
@@ -50,6 +50,7 @@ import { compareSkill, formatSkillComparison } from "./core/skill-compare.js";
50
50
  import { applyActivationChange, buildLibraryStateReport, formatActivationPlan, formatLibrarySummary, formatLibraryStateReport, planActivationChange, } from "./core/active-set.js";
51
51
  import { applyProjectActivation, formatProjectActivation, planProjectActivation, } from "./core/active-policy.js";
52
52
  import { applySkillAdoption, formatAdoptionPlan, planSkillAdoption, } from "./core/adopt.js";
53
+ import { applyReconcilePlan, buildReconcilePlan, formatReconcilePlan, } from "./core/reconcile.js";
53
54
  import { formatReviewQueue, mergeReviewQueue, readReviewQueue, setReviewDecision, } from "./core/review-queue.js";
54
55
  import { applyProviderModelSelection, defaultModelConfigurationPath, formatProviderModelConfiguration, planProviderModelSelection, readProviderModelConfiguration, requestOpenRouter, } from "./core/model-config.js";
55
56
  import { applyNativeScheduler, applyNativeSchedulerBundle, formatNativeScheduler, planNativeScheduler, } from "./core/scheduler.js";
@@ -263,7 +264,7 @@ async function runSetup(options) {
263
264
  reader?.close();
264
265
  }
265
266
  }
266
- const LOADOUT_VERSION = "0.5.3";
267
+ const LOADOUT_VERSION = "0.5.5";
267
268
  function durableSchedulerLauncher() {
268
269
  return [
269
270
  join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
@@ -1183,6 +1184,47 @@ program
1183
1184
  ? JSON.stringify(enriched, null, 2)
1184
1185
  : `${formatInstalledSkillInventory(enriched)}\n${formatProvenanceSummary(enriched)}`);
1185
1186
  });
1187
+ program
1188
+ .command("reconcile")
1189
+ .description("Match existing unmanaged skills to pinned sources, group mirrors, and preview safe ownership or updates")
1190
+ .option("--agents <ids>", "comma-separated agent ids; defaults to detected agents")
1191
+ .option("--refresh", "rebuild the inspected catalog skill index")
1192
+ .option("--replace-outdated", "replace unambiguous outdated trees with their reviewed pinned source")
1193
+ .option("--approve-risk", "approve the exact safety findings shown for outdated replacements")
1194
+ .option("--yes", "apply the displayed transaction; otherwise preview")
1195
+ .option("--json", "emit machine-readable output")
1196
+ .action(async (options) => {
1197
+ const detected = await detectAgents();
1198
+ const requested = parseAgentSelection(options.agents);
1199
+ const selected = requested?.length
1200
+ ? detected.filter((agent) => requested.includes(agent.id) && agent.installed)
1201
+ : detected.filter((agent) => agent.installed);
1202
+ if (!selected.length)
1203
+ throw new Error("No detected agent profile is available to reconcile");
1204
+ const resolved = await resolveCatalogSkillIndex({
1205
+ refresh: options.refresh,
1206
+ build: {
1207
+ catalog: await loadEffectiveCatalog(),
1208
+ onProgress: options.refresh ? printProvenanceProgress : undefined,
1209
+ },
1210
+ });
1211
+ if (!resolved.index)
1212
+ throw new Error("No reviewed catalog skill index is available");
1213
+ const plan = await buildReconcilePlan(selected, resolved.index);
1214
+ if (!options.yes) {
1215
+ console.log(options.json
1216
+ ? JSON.stringify(plan, null, 2)
1217
+ : `${formatReconcilePlan(plan)}\n\nDry run only. Re-run with --yes to adopt exact matches.${plan.summary.outdated ? " Add --replace-outdated to include the reviewed updates shown above." : ""}`);
1218
+ return;
1219
+ }
1220
+ const result = await applyReconcilePlan(plan, {
1221
+ replaceOutdated: options.replaceOutdated,
1222
+ approveRisk: options.approveRisk,
1223
+ });
1224
+ console.log(options.json
1225
+ ? JSON.stringify({ plan, result }, null, 2)
1226
+ : `${formatReconcilePlan(plan)}\n\nReconciled ${result.adopted} exact group(s) and ${result.updated} outdated group(s). Snapshot: ${result.snapshotId}`);
1227
+ });
1186
1228
  program
1187
1229
  .command("adopt")
1188
1230
  .description("Take ownership of one explicitly selected installed skill without changing its bytes")
@@ -4,6 +4,8 @@ START HERE
4
4
 
5
5
  1. See what Loadout currently manages
6
6
  loadout library
7
+ loadout scan
8
+ loadout reconcile
7
9
 
8
10
  2. Preview the recommended everyday setup
9
11
  loadout setup --mode stable
@@ -26,6 +28,9 @@ START HERE
26
28
  If you decide to install something, Loadout shows a preview first and creates a
27
29
  snapshot before changing managed files. Recover with: loadout rollback
28
30
 
31
+ Reconcile is also read-only unless you add --yes. Exact matches can be managed
32
+ without rewriting them; outdated replacements remain a separate explicit choice.
33
+
29
34
  Nothing above changes your agents. For the full maintainer/tooling surface, run:
30
35
  loadout advanced
31
36
  `.trim();
@@ -17,6 +17,7 @@ const commands = [
17
17
  "unadd",
18
18
  "list",
19
19
  "scan",
20
+ "reconcile",
20
21
  "status",
21
22
  "versions",
22
23
  "intelligence",
@@ -103,15 +103,24 @@ export async function buildHealthReport(options = {}) {
103
103
  fix: "Review the target, then remove and reinstall the owning runtime tool.",
104
104
  });
105
105
  const available = updates.filter((update) => update.status === "update-available");
106
- if (available.length)
106
+ const disabledAvailable = available.filter((update) => Boolean(update.disabledAgents?.length));
107
+ const activeAvailable = available.filter((update) => !update.disabledAgents?.length);
108
+ if (activeAvailable.length)
107
109
  findings.push({
108
- level: available.some((update) => update.approvalRequired)
110
+ level: activeAvailable.some((update) => update.approvalRequired)
109
111
  ? "warning"
110
112
  : "info",
111
113
  code: "updates-available",
112
- message: `${available.length} package update(s) are available.`,
114
+ message: `${activeAvailable.length} active package update(s) are available.`,
113
115
  fix: "Run loadout update and review the safety findings.",
114
116
  });
117
+ if (disabledAvailable.length)
118
+ findings.push({
119
+ level: "info",
120
+ code: "disabled-library-updates-available",
121
+ message: `${disabledAvailable.length} disabled-library package(s) have newer upstream commits.`,
122
+ fix: "Nothing active changed; disabled skills stay pinned and are never reactivated automatically.",
123
+ });
115
124
  const errors = updates.filter((update) => update.status === "error");
116
125
  if (errors.length)
117
126
  findings.push({
@@ -150,6 +159,9 @@ export async function buildHealthReport(options = {}) {
150
159
  managedRuntimeTools: runtimeTools.length,
151
160
  updatesChecked: Boolean(options.updates || options.checkUpdates),
152
161
  updatesAvailable: available.length,
162
+ activeUpdatesAvailable: activeAvailable.length,
163
+ disabledUpdatesAvailable: disabledAvailable.length,
164
+ updateChecksFailed: errors.length,
153
165
  driftedFiles: drifted.length,
154
166
  driftedMcpServers,
155
167
  findings,
@@ -165,7 +177,7 @@ export function formatHealthReport(report) {
165
177
  : "✗";
166
178
  const lines = [
167
179
  `${icon} Loadout health: ${report.status === "not-configured" ? "not configured" : report.status === "library-only" ? "library ready (nothing active)" : report.status}`,
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)`,
180
+ `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.activeUpdatesAvailable ?? report.updatesAvailable} active update(s), ${report.disabledUpdatesAvailable ?? 0} disabled-library update(s), ${report.updateChecksFailed ?? 0} check(s) unavailable` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
169
181
  ];
170
182
  for (const finding of report.findings)
171
183
  lines.push(`${finding.level === "ok" ? "✓" : finding.level === "error" ? "✗" : finding.level === "warning" ? "!" : "•"} ${finding.message}${finding.fix ? ` ${finding.fix}` : ""}`);
@@ -19,6 +19,7 @@ export const AGENT_DEFINITIONS = [
19
19
  // not exposed on the shell PATH. The shared Agent Skills root (~/.agents)
20
20
  // is also valid evidence when it already exists.
21
21
  detectionDirectories: [[".codex"], [".agents"]],
22
+ additionalSkillDirectories: [[".codex", "skills"]],
22
23
  },
23
24
  {
24
25
  id: "cursor",
@@ -181,6 +182,9 @@ export async function detectAgents(options = {}) {
181
182
  const path = platform === "win32" ? win32 : posix;
182
183
  const skillsDirectory = agentSkillsDirectory(definition.id, home, platform);
183
184
  const detectionDirectories = definition.detectionDirectories?.map((parts) => path.join(home, ...parts)) ?? [dirnameForDetection(skillsDirectory)];
185
+ const additionalSkillsDirectories = definition.additionalSkillDirectories
186
+ ?.map((parts) => path.join(home, ...parts))
187
+ .filter((directory) => directory !== skillsDirectory) ?? [];
184
188
  return {
185
189
  id: definition.id,
186
190
  displayName: definition.displayName,
@@ -190,6 +194,9 @@ export async function detectAgents(options = {}) {
190
194
  : false) ||
191
195
  (await Promise.all(detectionDirectories.map((directory) => directoryExists(directory)))).some(Boolean),
192
196
  skillsDirectory,
197
+ ...(additionalSkillsDirectories.length
198
+ ? { additionalSkillsDirectories }
199
+ : {}),
193
200
  };
194
201
  }));
195
202
  }
@@ -27,6 +27,7 @@ export function evaluateInstalledProfileState(state, catalog) {
27
27
  installed: false,
28
28
  expectedPackages: [],
29
29
  missingPackages: [],
30
+ newlyReviewedPackages: [],
30
31
  reviewedRevisionChanges: [],
31
32
  needsRefresh: false,
32
33
  evaluatedAt,
@@ -41,8 +42,16 @@ export function evaluateInstalledProfileState(state, catalog) {
41
42
  const expected = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
42
43
  const installed = new Map(state.installs.map((record) => [record.packageId, record]));
43
44
  const previousCatalog = new Map(state.profile.catalogPackages.map((record) => [record.packageId, record]));
44
- const missingPackages = expected
45
- .filter((pkg) => !installed.has(pkg.id))
45
+ const representedRepositories = new Set(state.installs
46
+ .map((record) => record.repository?.toLowerCase())
47
+ .filter((repository) => Boolean(repository)));
48
+ const missingPackages = state.profile.catalogPackages
49
+ .filter((item) => !installed.has(item.packageId))
50
+ .map((item) => item.packageId);
51
+ const newlyReviewedPackages = expected
52
+ .filter((pkg) => !previousCatalog.has(pkg.id) &&
53
+ !installed.has(pkg.id) &&
54
+ !representedRepositories.has(pkg.repository.toLowerCase()))
46
55
  .map((pkg) => pkg.id);
47
56
  const reviewedRevisionChanges = expected.flatMap((pkg) => {
48
57
  const previous = previousCatalog.get(pkg.id);
@@ -58,21 +67,16 @@ export function evaluateInstalledProfileState(state, catalog) {
58
67
  },
59
68
  ];
60
69
  });
61
- const oldIds = state.profile.catalogPackages
62
- .map((item) => item.packageId)
63
- .sort();
64
70
  const expectedPackages = expected.map((pkg) => pkg.id).sort();
65
- const selectionChanged = oldIds.join("\0") !== expectedPackages.join("\0");
66
71
  return {
67
72
  installed: true,
68
73
  mode: state.profile.mode,
69
74
  appliedAt: state.profile.appliedAt,
70
75
  expectedPackages,
71
76
  missingPackages,
77
+ newlyReviewedPackages,
72
78
  reviewedRevisionChanges,
73
- needsRefresh: selectionChanged ||
74
- missingPackages.length > 0 ||
75
- reviewedRevisionChanges.length > 0,
79
+ needsRefresh: newlyReviewedPackages.length > 0 || reviewedRevisionChanges.length > 0,
76
80
  evaluatedAt,
77
81
  boundary,
78
82
  };
@@ -87,7 +91,14 @@ export function formatInstalledProfileStatus(status) {
87
91
  `PROFILE ${status.mode?.toUpperCase()}: ${status.needsRefresh ? "reviewed changes available" : "current against reviewed catalog"}`,
88
92
  `Expected repositories: ${status.expectedPackages.length}`,
89
93
  ...(status.missingPackages.length
90
- ? [`Missing: ${status.missingPackages.join(", ")}`]
94
+ ? [
95
+ `Not installed from the saved profile (removed, quarantined, or unavailable): ${status.missingPackages.join(", ")}`,
96
+ ]
97
+ : []),
98
+ ...(status.newlyReviewedPackages.length
99
+ ? [
100
+ `New reviewed catalog sources to inspect: ${status.newlyReviewedPackages.join(", ")}`,
101
+ ]
91
102
  : []),
92
103
  ...(status.reviewedRevisionChanges.length
93
104
  ? [
@@ -105,7 +105,14 @@ export async function buildCatalogSkillIndex(options = {}) {
105
105
  });
106
106
  if (snapshot.commit.toLowerCase() !== pkg.source.commit.toLowerCase())
107
107
  throw new Error(`resolved ${snapshot.commit}, expected ${pkg.source.commit}`);
108
- const directories = await discoverSkillDirectories(snapshot.path);
108
+ const rejected = [];
109
+ const directories = await discoverSkillDirectories(snapshot.path, {
110
+ continueOnRejected: true,
111
+ onRejected: (skill) => rejected.push({
112
+ name: skill.name ?? skill.targetName,
113
+ reason: skill.reason,
114
+ }),
115
+ });
109
116
  const records = await Promise.all(directories.map(async (directory) => {
110
117
  const content = await readFile(join(directory, "SKILL.md"), "utf8");
111
118
  const description = cleanFrontmatterValue(content.match(/^description:\s*(.+)$/m)?.[1]);
@@ -131,9 +138,23 @@ export async function buildCatalogSkillIndex(options = {}) {
131
138
  completed,
132
139
  total: packages.length,
133
140
  status: "ready",
134
- message: `${pkg.displayName}: ${records.length} reviewed skill(s) indexed`,
141
+ message: `${pkg.displayName}: ${records.length} reviewed skill(s) indexed${rejected.length ? `; ${rejected.length} quarantined` : ""}`,
135
142
  });
136
- return { records };
143
+ return {
144
+ records,
145
+ ...(rejected.length
146
+ ? {
147
+ failure: {
148
+ packageId: pkg.id,
149
+ repository: pkg.repository,
150
+ error: `${rejected.length} skill unit(s) quarantined: ${rejected
151
+ .map((item) => item.name)
152
+ .sort()
153
+ .join(", ")}`,
154
+ },
155
+ }
156
+ : {}),
157
+ };
137
158
  }
138
159
  catch (error) {
139
160
  completed += 1;
@@ -271,8 +292,10 @@ export function enrichInventoryWithProvenance(report, index, source = "none") {
271
292
  }
272
293
  export function formatProvenanceSummary(report) {
273
294
  const value = report.provenance;
295
+ const quarantinedSources = value.failures.filter((failure) => /^\d+ skill unit\(s\) quarantined:/.test(failure.error)).length;
296
+ const preparationFailures = value.failures.length - quarantinedSources;
274
297
  return [
275
298
  `Provenance: ${value.exact} exact catalog match(es), ${value.managed} Loadout-managed, ${value.embedded} embedded-source candidate(s), ${value.nameCandidates} name-only candidate(s), ${value.unknown} unknown`,
276
- `Catalog skill index: ${value.indexSource}; ${value.indexedSkills} reviewed skill(s)${value.failures.length ? `; ${value.failures.length} repository failure(s)` : ""}`,
299
+ `Catalog skill index: ${value.indexSource}; ${value.indexedSkills} reviewed skill(s)${quarantinedSources ? `; ${quarantinedSources} source(s) contain quarantined units` : ""}${preparationFailures ? `; ${preparationFailures} source preparation failure(s)` : ""}`,
277
300
  ].join("\n");
278
301
  }
@@ -0,0 +1,300 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cp, lstat, readFile, readdir, rm } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ import { analyzeUpdateSafety } from "./safety.js";
5
+ import { enrichInventoryWithProvenance, } from "./provenance.js";
6
+ import { scanInstalledSkills } from "./skill-inventory.js";
7
+ import { installStatePath, recordInstallBatch } from "./state.js";
8
+ import { runMutationTransaction } from "./transaction.js";
9
+ import { repositoryCachePath } from "./source.js";
10
+ import { validateSkillDirectory } from "./skills.js";
11
+ const issuedPlans = new WeakMap();
12
+ const IGNORED_TREE_NAMES = new Set([".git", ".cache", "node_modules"]);
13
+ function slug(value) {
14
+ return (value
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-|-$/g, "")
18
+ .slice(0, 48) || "skill");
19
+ }
20
+ async function captureTree(root) {
21
+ const rootInfo = await lstat(root);
22
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
23
+ throw new Error(`Refusing unsafe reconciliation root: ${root}`);
24
+ const entries = [];
25
+ async function visit(directory) {
26
+ const children = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
27
+ for (const child of children) {
28
+ if (IGNORED_TREE_NAMES.has(child.name))
29
+ continue;
30
+ const path = join(directory, child.name);
31
+ if (child.isSymbolicLink())
32
+ throw new Error(`Refusing symlink in reconciliation tree: ${path}`);
33
+ if (child.isDirectory())
34
+ await visit(path);
35
+ else if (child.isFile())
36
+ entries.push({
37
+ path: relative(root, path).split(sep).join("/"),
38
+ sha256: createHash("sha256")
39
+ .update(await readFile(path))
40
+ .digest("hex"),
41
+ });
42
+ }
43
+ }
44
+ await visit(root);
45
+ return entries;
46
+ }
47
+ function treeSignature(tree) {
48
+ return JSON.stringify(tree);
49
+ }
50
+ function uniqueFindings(findings) {
51
+ return [
52
+ ...new Map(findings.map((finding) => [
53
+ `${finding.severity}:${finding.category}:${finding.message}:${finding.paths.join(",")}:${finding.names?.join(",") ?? ""}`,
54
+ finding,
55
+ ])).values(),
56
+ ];
57
+ }
58
+ function planSignature(plan) {
59
+ return JSON.stringify(plan.items.map((item) => ({
60
+ name: item.name,
61
+ fingerprint: item.fingerprint,
62
+ status: item.status,
63
+ packageId: item.packageId,
64
+ candidate: item.candidate
65
+ ? [
66
+ item.candidate.packageId,
67
+ item.candidate.commit,
68
+ item.candidate.skillPath,
69
+ ]
70
+ : undefined,
71
+ installations: item.installations.map((installation) => ({
72
+ agent: installation.agent,
73
+ path: installation.path,
74
+ tree: installation.tree,
75
+ })),
76
+ })));
77
+ }
78
+ function candidateSource(candidate) {
79
+ return join(repositoryCachePath(candidate.repository, candidate.commit), candidate.skillPath);
80
+ }
81
+ async function itemFor(name, fingerprint, installations, index) {
82
+ const captured = await Promise.all(installations.map(async (installation) => ({
83
+ ...installation,
84
+ tree: await captureTree(installation.path),
85
+ })));
86
+ const namedCandidates = index.records.filter((record) => record.skillName.trim().toLowerCase() === name.toLowerCase());
87
+ const repositoryOrigins = new Set(installations
88
+ .flatMap((installation) => installation.repositoryOrigin ?? [])
89
+ .map((origin) => origin.toLowerCase()));
90
+ const hintedCandidates = namedCandidates.filter((record) => repositoryOrigins.has(record.repository.toLowerCase()));
91
+ const candidates = hintedCandidates.length
92
+ ? hintedCandidates
93
+ : namedCandidates;
94
+ const available = (await Promise.all(candidates.map(async (candidate) => {
95
+ const sourcePath = candidateSource(candidate);
96
+ try {
97
+ const tree = await captureTree(sourcePath);
98
+ return { candidate, sourcePath, tree };
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }))).filter((item) => Boolean(item));
104
+ const exact = available.filter((entry) => captured.every((installation) => treeSignature(installation.tree) === treeSignature(entry.tree)));
105
+ const selected = exact.length === 1 ? exact[0] : available[0];
106
+ const packageId = selected
107
+ ? `adopted-${slug(selected.candidate.packageId)}-${slug(name)}-${fingerprint.slice(0, 8)}`
108
+ : undefined;
109
+ if (exact.length === 1 && selected)
110
+ return {
111
+ name,
112
+ fingerprint,
113
+ status: "exact",
114
+ installations: captured,
115
+ packageId,
116
+ candidate: selected.candidate,
117
+ sourcePath: selected.sourcePath,
118
+ safetyFindings: [],
119
+ approvalRequired: false,
120
+ reason: "Every installed file exactly matches one pinned catalog skill tree.",
121
+ };
122
+ if (available.length === 1 && selected) {
123
+ const safety = uniqueFindings((await Promise.all(captured.map((installation) => analyzeUpdateSafety(installation.path, selected.sourcePath)))).flatMap((analysis) => analysis.findings));
124
+ return {
125
+ name,
126
+ fingerprint,
127
+ status: "outdated",
128
+ installations: captured,
129
+ packageId,
130
+ candidate: selected.candidate,
131
+ sourcePath: selected.sourcePath,
132
+ safetyFindings: safety,
133
+ approvalRequired: safety.some((finding) => finding.severity === "blocking"),
134
+ reason: "One reviewed same-name upstream skill exists, but the installed tree differs.",
135
+ };
136
+ }
137
+ if (available.length > 1)
138
+ return {
139
+ name,
140
+ fingerprint,
141
+ status: "ambiguous",
142
+ installations: captured,
143
+ safetyFindings: [],
144
+ approvalRequired: false,
145
+ reason: `${available.length} reviewed same-name candidates exist; Loadout will not guess which source owns these bytes.`,
146
+ };
147
+ return {
148
+ name,
149
+ fingerprint,
150
+ status: "unknown",
151
+ installations: captured,
152
+ safetyFindings: [],
153
+ approvalRequired: false,
154
+ reason: "No reviewed same-name catalog source is available.",
155
+ };
156
+ }
157
+ export async function buildReconcilePlan(agents, index) {
158
+ const inventory = enrichInventoryWithProvenance(await scanInstalledSkills(agents), index, "cache");
159
+ const groups = new Map();
160
+ for (const skill of inventory.skills.filter((entry) => !entry.managed)) {
161
+ const key = `${skill.name.toLowerCase()}\0${skill.fingerprint}`;
162
+ const values = groups.get(key) ?? [];
163
+ values.push({
164
+ agent: skill.agent,
165
+ path: skill.path,
166
+ name: skill.name,
167
+ fingerprint: skill.fingerprint,
168
+ ...(skill.repositoryOrigin
169
+ ? { repositoryOrigin: skill.repositoryOrigin }
170
+ : {}),
171
+ });
172
+ groups.set(key, values);
173
+ }
174
+ const items = await Promise.all([...groups.values()].map((group) => itemFor(group[0].name, group[0].fingerprint, group.map(({ agent, path, repositoryOrigin }) => ({
175
+ agent,
176
+ path,
177
+ ...(repositoryOrigin ? { repositoryOrigin } : {}),
178
+ })), index)));
179
+ items.sort((left, right) => left.status.localeCompare(right.status) ||
180
+ left.name.localeCompare(right.name));
181
+ const count = (status) => items.filter((item) => item.status === status).length;
182
+ const plan = {
183
+ generatedAt: new Date().toISOString(),
184
+ items,
185
+ summary: {
186
+ existing: items.reduce((total, item) => total + item.installations.length, 0),
187
+ exact: count("exact"),
188
+ outdated: count("outdated"),
189
+ ambiguous: count("ambiguous"),
190
+ unknown: count("unknown"),
191
+ mirroredGroups: items.filter((item) => item.installations.length > 1)
192
+ .length,
193
+ },
194
+ };
195
+ issuedPlans.set(plan, { signature: planSignature(plan) });
196
+ return plan;
197
+ }
198
+ function installPlanFor(item, replace) {
199
+ if (!item.packageId || !item.candidate || !item.sourcePath)
200
+ throw new Error(`Reconciliation item '${item.name}' has no reviewed source`);
201
+ return {
202
+ packageId: item.packageId,
203
+ targetAgents: [...new Set(item.installations.map((entry) => entry.agent))],
204
+ warnings: [],
205
+ files: item.installations.map((installation) => ({
206
+ source: replace ? item.sourcePath : installation.path,
207
+ target: installation.path,
208
+ targetAgent: installation.agent,
209
+ componentType: "skill",
210
+ compatibility: "native",
211
+ skillName: item.name,
212
+ })),
213
+ };
214
+ }
215
+ async function assertPlanUnchanged(plan) {
216
+ const issued = issuedPlans.get(plan);
217
+ if (!issued || issued.signature !== planSignature(plan))
218
+ throw new Error("Reconciliation plan changed after preview; preview again.");
219
+ for (const item of plan.items)
220
+ for (const installation of item.installations)
221
+ if (treeSignature(await captureTree(installation.path)) !==
222
+ treeSignature(installation.tree))
223
+ throw new Error(`Existing skill changed after preview: ${installation.path}`);
224
+ }
225
+ export async function applyReconcilePlan(plan, options = {}) {
226
+ await assertPlanUnchanged(plan);
227
+ const exact = plan.items.filter((item) => item.status === "exact");
228
+ const outdated = options.replaceOutdated
229
+ ? plan.items.filter((item) => item.status === "outdated")
230
+ : [];
231
+ const selected = [...exact, ...outdated];
232
+ if (!selected.length)
233
+ throw new Error("No exact or explicitly selected outdated skills to reconcile");
234
+ const risky = outdated.filter((item) => item.approvalRequired);
235
+ if (risky.length && !options.approveRisk)
236
+ throw new Error(`Replacing ${risky.length} outdated skill group(s) requires --approve-risk after reviewing the reported findings.`);
237
+ const entries = selected.map((item) => ({
238
+ plan: installPlanFor(item, item.status === "outdated"),
239
+ metadata: {
240
+ repository: item.candidate.repository,
241
+ resolvedCommit: item.candidate.commit,
242
+ reviewed: true,
243
+ },
244
+ }));
245
+ const outdatedTargets = outdated.flatMap((item) => item.installations.map((installation) => installation.path));
246
+ const applied = await runMutationTransaction(async () => {
247
+ await assertPlanUnchanged(plan);
248
+ return {
249
+ targets: [...outdatedTargets, installStatePath()],
250
+ value: entries,
251
+ };
252
+ }, async (freshEntries, snapshot) => {
253
+ for (const entry of freshEntries.filter((candidate) => outdated.some((item) => item.packageId === candidate.plan.packageId)))
254
+ for (const file of entry.plan.files) {
255
+ await rm(file.target, { recursive: true, force: true });
256
+ await cp(file.source, file.target, {
257
+ recursive: true,
258
+ filter: (source) => !source.split(sep).some((part) => IGNORED_TREE_NAMES.has(part)),
259
+ });
260
+ await validateSkillDirectory(file.target);
261
+ if (treeSignature(await captureTree(file.target)) !==
262
+ treeSignature(await captureTree(file.source)))
263
+ throw new Error(`Exact reconciliation copy verification failed: ${file.target}`);
264
+ }
265
+ await recordInstallBatch(freshEntries, snapshot.id);
266
+ }, { label: "reconcile existing skills" });
267
+ return {
268
+ snapshotId: applied.snapshotId,
269
+ adopted: exact.length,
270
+ updated: outdated.length,
271
+ };
272
+ }
273
+ export function formatReconcilePlan(plan) {
274
+ const lines = [
275
+ "Existing skill reconciliation",
276
+ "",
277
+ `${plan.summary.existing} installed copy/copies in ${plan.items.length} unique version group(s)`,
278
+ `${plan.summary.exact} exact upstream match(es)`,
279
+ `${plan.summary.outdated} reviewed update candidate(s)`,
280
+ `${plan.summary.ambiguous} ambiguous group(s)`,
281
+ `${plan.summary.unknown} unknown group(s)`,
282
+ `${plan.summary.mirroredGroups} cross-agent mirror group(s)`,
283
+ ];
284
+ for (const item of plan.items) {
285
+ const marker = item.status === "exact"
286
+ ? "✓"
287
+ : item.status === "outdated"
288
+ ? "↑"
289
+ : item.status === "ambiguous"
290
+ ? "?"
291
+ : "·";
292
+ lines.push("", `${marker} ${item.name} — ${item.status} — ${item.installations.map((entry) => entry.agent).join(", ")}`, ` ${item.reason}`);
293
+ if (item.candidate)
294
+ lines.push(` Source: ${item.candidate.repository}@${item.candidate.commit.slice(0, 12)} (${item.candidate.skillPath})`);
295
+ if (item.safetyFindings.length)
296
+ lines.push(` Review: ${item.safetyFindings.length} safety-sensitive update finding(s)${item.approvalRequired ? "; explicit approval required" : ""}`, ...item.safetyFindings.map((finding) => ` - ${finding.severity}/${finding.category}: ${finding.message}${finding.paths.length ? ` [${finding.paths.slice(0, 3).join(", ")}${finding.paths.length > 3 ? ", …" : ""}]` : ""}`));
297
+ }
298
+ lines.push("", "Default apply adopts exact matches without changing their files.", "Outdated replacements require --replace-outdated and remain one rollback-safe transaction.");
299
+ return lines.join("\n");
300
+ }
@@ -37,6 +37,7 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
37
37
  const primaryRoot = resolve(agent.skillsDirectory);
38
38
  const roots = [
39
39
  primaryRoot,
40
+ ...(agent.additionalSkillsDirectories ?? []).map((path) => resolve(path)),
40
41
  ...runtimeTargets
41
42
  .filter((target) => target.agent === agent.id)
42
43
  .map((target) => resolve(target.path))
@@ -72,11 +73,31 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
72
73
  const path = join(directory, "SKILL.md");
73
74
  const content = await readFile(path, "utf8");
74
75
  const packageId = owningPackage(directory, agent.id, records, activations, runtimeTargets);
75
- const sourceHints = [
76
+ let gitConfig = "";
77
+ try {
78
+ const gitConfigPath = join(directory, ".git", "config");
79
+ const gitConfigInfo = await lstat(gitConfigPath);
80
+ if (gitConfigInfo.isFile() && !gitConfigInfo.isSymbolicLink())
81
+ gitConfig = await readFile(gitConfigPath, "utf8");
82
+ }
83
+ catch {
84
+ // Most installed skills are copied directories, not Git worktrees.
85
+ }
86
+ const originSection = gitConfig.match(/\[remote\s+["']origin["']\]([\s\S]*?)(?=\n\s*\[|$)/i)?.[1] ?? "";
87
+ const gitOrigins = [
76
88
  ...new Set([
77
- ...content.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
89
+ ...originSection.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
90
+ ...originSection.matchAll(/git@github\.com:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
78
91
  ].map((match) => match[1].replace(/\.git$/, ""))),
79
92
  ].sort();
93
+ const sourceHints = [
94
+ ...new Set([
95
+ ...[
96
+ ...content.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
97
+ ].map((match) => match[1].replace(/\.git$/, "")),
98
+ ...gitOrigins,
99
+ ]),
100
+ ].sort();
80
101
  skills.push({
81
102
  agent: agent.id,
82
103
  agentDisplayName: agent.displayName,
@@ -93,6 +114,9 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
93
114
  managed: Boolean(packageId),
94
115
  ...(packageId ? { packageId } : {}),
95
116
  ...(sourceHints.length ? { sourceHints } : {}),
117
+ ...(gitOrigins.length === 1
118
+ ? { repositoryOrigin: gitOrigins[0] }
119
+ : {}),
96
120
  });
97
121
  }
98
122
  catch (error) {
@@ -106,6 +130,7 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
106
130
  }
107
131
  for (const child of children) {
108
132
  if (child.name === ".git" ||
133
+ child.name === ".system" ||
109
134
  child.name === "node_modules" ||
110
135
  child.name === ".cache")
111
136
  continue;
@@ -113,7 +138,8 @@ async function scanAgentSkills(agent, records, activations, runtimeTargets) {
113
138
  await visit(join(directory, child.name), depth + 1);
114
139
  }
115
140
  }
116
- for (const root of [...new Set(roots)])
141
+ const scanRoots = [...new Set(roots)].filter((root, index, values) => !values.some((candidate, candidateIndex) => candidateIndex !== index && isInside(candidate, root)));
142
+ for (const root of scanRoots)
117
143
  await visit(root, 0);
118
144
  return {
119
145
  skills: skills.sort((left, right) => left.path.localeCompare(right.path)),
@@ -173,6 +199,9 @@ export async function scanInstalledSkills(agents) {
173
199
  unmanaged: entries.length - managed,
174
200
  overRecommendedLimit: entries.length > RECOMMENDED_ACTIVE_SKILLS,
175
201
  runtimeToolTargets: runtimeTargets.filter((target) => target.agent === agent.id).length,
202
+ ...(agent.additionalSkillsDirectories?.length
203
+ ? { additionalDirectories: [...agent.additionalSkillsDirectories] }
204
+ : {}),
176
205
  };
177
206
  });
178
207
  for (const summary of summaries.filter((item) => item.overRecommendedLimit))
@@ -201,7 +230,7 @@ export function formatInstalledSkillInventory(report) {
201
230
  "",
202
231
  ];
203
232
  for (const agent of report.agents)
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}`);
233
+ 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, ...(agent.additionalDirectories ?? [])].join(", ")}`);
205
234
  for (const warning of report.warnings)
206
235
  lines.push(`! ${warning}`);
207
236
  lines.push("", "Read-only scan complete. Unmanaged content was not changed or judged automatically.");
@@ -158,6 +158,7 @@ export async function scanSkillSecurity(skillRoot, options = {}) {
158
158
  const contents = new Map();
159
159
  const findings = [];
160
160
  let totalBytes = 0;
161
+ const ignoredDirectories = new Set([".git", ".cache", "node_modules"]);
161
162
  async function visit(directory, depth) {
162
163
  if (depth > 12) {
163
164
  findings.push(finding("tree-depth", "high", "size", "Skill tree exceeds the bounded inspection depth.", [normalizedPath(root, directory)]));
@@ -165,6 +166,8 @@ export async function scanSkillSecurity(skillRoot, options = {}) {
165
166
  }
166
167
  const entries = await readdir(directory, { withFileTypes: true });
167
168
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
169
+ if (entry.isDirectory() && ignoredDirectories.has(entry.name))
170
+ continue;
168
171
  const absolute = join(directory, entry.name);
169
172
  const path = normalizedPath(root, absolute);
170
173
  const info = await lstat(absolute);
@@ -91,6 +91,15 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
91
91
  ? state.installs.filter((record) => record.packageId === options.packageId)
92
92
  : state.installs;
93
93
  const results = new Array(records.length);
94
+ const resolutions = new Map();
95
+ const resolveOnce = (repository) => {
96
+ const existing = resolutions.get(repository);
97
+ if (existing)
98
+ return existing;
99
+ const pending = resolver(repository);
100
+ resolutions.set(repository, pending);
101
+ return pending;
102
+ };
94
103
  let cursor = 0;
95
104
  let completed = 0;
96
105
  const workers = Array.from({
@@ -100,17 +109,21 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
100
109
  const index = cursor++;
101
110
  const record = records[index];
102
111
  results[index] = await (async () => {
103
- const disabledAgents = (state.activations ?? [])
104
- .filter((activation) => activation.packageId === record.packageId &&
112
+ const disabledActivations = (state.activations ?? []).filter((activation) => activation.packageId === record.packageId &&
105
113
  activation.installationState === "installed" &&
106
- activation.activationState === "disabled")
107
- .map((activation) => activation.agent);
114
+ activation.activationState === "disabled");
115
+ const disabledAgents = [
116
+ ...new Set(disabledActivations.map((activation) => activation.agent)),
117
+ ].sort();
108
118
  const base = {
109
119
  packageId: record.packageId,
110
120
  repository: record.repository,
111
121
  installedCommit: record.resolvedCommit,
112
122
  targetAgents: record.targetAgents,
113
123
  ...(disabledAgents.length ? { disabledAgents } : {}),
124
+ ...(disabledActivations.length
125
+ ? { disabledUnits: disabledActivations.length }
126
+ : {}),
114
127
  };
115
128
  if (!record.repository || !record.resolvedCommit) {
116
129
  return {
@@ -120,7 +133,7 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
120
133
  };
121
134
  }
122
135
  try {
123
- const current = await resolver(record.repository);
136
+ const current = await resolveOnce(record.repository);
124
137
  const same = current.commit.toLowerCase() ===
125
138
  record.resolvedCommit.toLowerCase();
126
139
  let diff;
@@ -140,7 +153,7 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
140
153
  action: same
141
154
  ? "No action required."
142
155
  : disabledAgents.length
143
- ? `Enable ${record.packageId} for ${disabledAgents.join(", ")} before applying an update; planning remains read-only.`
156
+ ? `A newer upstream commit exists, but ${disabledActivations.length} reviewed library ${disabledActivations.length === 1 ? "copy is" : "copies are"} disabled for ${disabledAgents.join(" and ")}. Nothing active changed; Loadout will not update or reactivate disabled skills automatically.`
144
157
  : approvalRequired
145
158
  ? `Approval required: review safety warnings before updating ${record.packageId}.`
146
159
  : `Run loadout update --package ${record.packageId} --yes after review.`,
@@ -172,16 +185,35 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
172
185
  export function formatUpdatePlan(plans) {
173
186
  if (plans.length === 0)
174
187
  return "No tracked installations found.";
175
- return plans
176
- .map((plan) => {
188
+ const available = plans.filter((plan) => plan.status === "update-available");
189
+ const activeAvailable = available.filter((plan) => !plan.disabledAgents?.length);
190
+ const disabledAvailable = available.filter((plan) => Boolean(plan.disabledAgents?.length));
191
+ const current = plans.filter((plan) => plan.status === "up-to-date");
192
+ const unavailable = plans.filter((plan) => plan.status === "error" || plan.status === "untracked");
193
+ const lines = [
194
+ `Update check: ${activeAvailable.length} active update(s), ${disabledAvailable.length} disabled-library update(s), ${current.length} current, ${unavailable.length} unavailable.`,
195
+ ];
196
+ const visible = plans.length === 1
197
+ ? plans
198
+ : plans.filter((plan) => plan.status !== "up-to-date");
199
+ lines.push(...visible.map((plan) => {
177
200
  const suffix = plan.error ? ` — ${plan.error}` : "";
178
201
  const repo = plan.repository ? ` (${plan.repository})` : "";
179
- const safety = plan.safetyFindings
180
- ?.map((finding) => ` [${finding.severity}] ${finding.message}${finding.names?.length ? ` (${finding.names.join(", ")})` : ""}`)
181
- .join("") ?? "";
202
+ const findingCounts = new Map();
203
+ for (const finding of plan.safetyFindings ?? []) {
204
+ const key = `${finding.severity} ${finding.category}`;
205
+ findingCounts.set(key, (findingCounts.get(key) ?? 0) + 1);
206
+ }
207
+ const safety = findingCounts.size
208
+ ? ` Safety review: ${[...findingCounts.entries()]
209
+ .map(([key, count]) => `${key}${count > 1 ? ` ×${count}` : ""}`)
210
+ .join(", ")}. Run loadout update --package ${plan.packageId} for the focused review.`
211
+ : "";
182
212
  return `${plan.status.toUpperCase()} ${plan.packageId}${repo}: ${plan.action}${safety}${suffix}`;
183
- })
184
- .join("\n");
213
+ }));
214
+ if (current.length && plans.length > 1)
215
+ lines.push(`Current packages hidden for readability (${current.length}); use --json for every record.`);
216
+ return lines.join("\n");
185
217
  }
186
218
  /** Updates safe enough for an explicit whole-profile `update --yes` apply. */
187
219
  export function selectSafeAutomaticUpdates(plans) {
@@ -236,11 +268,13 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
236
268
  const record = state.installs.find((item) => item.packageId === packageId);
237
269
  if (!record)
238
270
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
239
- const disabledAgents = (state.activations ?? [])
240
- .filter((activation) => activation.packageId === packageId &&
241
- activation.installationState === "installed" &&
242
- activation.activationState === "disabled")
243
- .map((activation) => activation.agent);
271
+ const disabledAgents = [
272
+ ...new Set((state.activations ?? [])
273
+ .filter((activation) => activation.packageId === packageId &&
274
+ activation.installationState === "installed" &&
275
+ activation.activationState === "disabled")
276
+ .map((activation) => activation.agent)),
277
+ ].sort();
244
278
  const expectedRecord = JSON.stringify(record);
245
279
  const expectedActivations = JSON.stringify((state.activations ?? [])
246
280
  .filter((activation) => activation.packageId === packageId)
@@ -265,12 +299,33 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
265
299
  .join(" ")}`);
266
300
  }
267
301
  const agents = installedAgents(await (runtime.detectAgents ?? detectAgents)(), record.targetAgents);
268
- const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
302
+ let plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
269
303
  ? {
270
304
  include: (skill) => units.some((unitId) => unitId.toLowerCase() === skill.name?.toLowerCase() ||
271
305
  unitId.toLowerCase() === skill.targetName.toLowerCase()),
272
306
  }
273
307
  : {});
308
+ if (units.length) {
309
+ const activeTargets = new Map((state.activations ?? [])
310
+ .filter((activation) => activation.packageId === packageId &&
311
+ activation.installationState === "installed" &&
312
+ activation.activationState === "active" &&
313
+ activation.unitId)
314
+ .flatMap((activation) => activation.targets.map((target) => [
315
+ `${activation.agent}\0${activation.unitId.toLowerCase()}`,
316
+ target.activePath,
317
+ ])));
318
+ plan = {
319
+ ...plan,
320
+ files: plan.files.map((file) => {
321
+ const unitId = file.skillName ?? basename(file.target);
322
+ const target = file.targetAgent
323
+ ? activeTargets.get(`${file.targetAgent}\0${unitId.toLowerCase()}`)
324
+ : undefined;
325
+ return target ? { ...file, target } : file;
326
+ }),
327
+ };
328
+ }
274
329
  if (units.length) {
275
330
  const plannedUnits = [
276
331
  ...new Set(plan.files.map((file) => basename(file.target))),
package/docs/CATALOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Catalog and upstream credits
2
2
 
3
- Loadout's bundled catalog indexes 51 public upstream repositories. This page gives every project a direct source link and exposes the exact reviewed revision used by Loadout.
3
+ Loadout's bundled catalog indexes 53 public upstream repositories. This page gives every project a direct source link and exposes the exact reviewed revision used by Loadout.
4
4
 
5
5
  Thank you to every maintainer and contributor whose work appears here. Loadout does not own, endorse for its authors, or relicense these projects. Each project remains governed by its upstream license and current terms.
6
6
 
@@ -14,7 +14,7 @@ Thank you to every maintainer and contributor whose work appears here. Loadout d
14
14
 
15
15
  The machine-readable catalog remains the source of truth. Run `loadout catalog --coverage --json` to inspect coverage and `loadout capabilities` to inspect what each local agent adapter can actually manage.
16
16
 
17
- ## All 51 credited repositories
17
+ ## All 53 credited repositories
18
18
 
19
19
  | Upstream source | Loadout ID | Category | Reviewed components | License status | Reviewed revision |
20
20
  | --------------------------------------------------------------------------------- | -------------------------------- | ------------------------ | -------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
@@ -69,6 +69,8 @@ The machine-readable catalog remains the source of truth. Run `loadout catalog -
69
69
  | [Browserbase MCP Server](https://github.com/browserbase/mcp-server-browserbase) | `browserbase-mcp-server` | browser-automation | `mcp` | `Apache-2.0` | [`1e196b3d3c4d`](https://github.com/browserbase/mcp-server-browserbase/tree/1e196b3d3c4dc70944e0d19038dd9eb3608b207a) |
70
70
  | [Bright Data MCP](https://github.com/brightdata/brightdata-mcp) | `bright-data-mcp` | web-research | `mcp` | `MIT` | [`adbfdd49b880`](https://github.com/brightdata/brightdata-mcp/tree/adbfdd49b8805136662a264490118c2688da3e29) |
71
71
  | [Obsidian Skills](https://github.com/kepano/obsidian-skills) | `obsidian-skills` | knowledge-management | `skill`, `plugin` | `MIT` | [`a1dc48e68138`](https://github.com/kepano/obsidian-skills/tree/a1dc48e68138490d522c04cbf5822214c6eb1202) |
72
+ | [Cloudflare Skills](https://github.com/cloudflare/skills) | `cloudflare-skills` | cloud-platform | `skill`, `plugin`, `mcp` | `Apache-2.0` | [`70215303d44a`](https://github.com/cloudflare/skills/tree/70215303d44a81a0db3219428f4825b604fc6061) |
73
+ | [Humanizer](https://github.com/blader/humanizer) | `humanizer` | writing | `skill` | `MIT` | [`1b48564898e9`](https://github.com/blader/humanizer/tree/1b48564898e999219882660237fde01bf4843a0f) |
72
74
 
73
75
  ## Catalog maintenance
74
76
 
@@ -20,6 +20,34 @@ record for every skill, use `loadout library --all`. `scan` distinguishes
20
20
  Loadout-managed skills from your own pre-existing skills. `health` checks local drift only; add
21
21
  `--updates` only when you want it to contact the tracked public repositories.
22
22
 
23
+ ### Compare and manage pre-existing skills
24
+
25
+ ```bash
26
+ # Refresh pinned source evidence and preview every existing unmanaged group
27
+ loadout reconcile --refresh
28
+
29
+ # Adopt only exact matches. This records provenance without rewriting skill files.
30
+ loadout reconcile --yes
31
+
32
+ # Preview old, unambiguous copies separately. Do not apply yet.
33
+ loadout reconcile --replace-outdated
34
+ ```
35
+
36
+ The preview groups identical copies shared by Claude, Codex, Cursor, and Windsurf.
37
+ `exact` means the complete distributable tree matches one pinned catalog skill;
38
+ `outdated` means one source is unambiguous but its tree differs; `ambiguous` and
39
+ `unknown` are deliberately left unmanaged. Only after reviewing every displayed
40
+ file, domain, script, environment, and instruction finding should you consider:
41
+
42
+ ```bash
43
+ loadout reconcile --replace-outdated --yes --approve-risk
44
+ ```
45
+
46
+ That replacement is one snapshot-backed transaction. Use `loadout rollback --list`
47
+ and the printed snapshot ID to undo it. Later `loadout update` checks the tracked
48
+ upstream repository for newer commits; it still previews and safety-checks changes
49
+ instead of silently updating files.
50
+
23
51
  ## 2. Explore without installing
24
52
 
25
53
  ```bash
@@ -183,7 +211,7 @@ cleanup deliberately deletes Loadout's snapshots, so it is the last lifecycle te
183
211
  ## Troubleshooting and recovery
184
212
 
185
213
  - **`loadout` is not found after installation:** confirm `npm install --global
186
- loadout-ai@0.5.3` completed, run `hash -r`, and confirm npm's global binary
214
+ loadout-ai@0.5.5` completed, run `hash -r`, and confirm npm's global binary
187
215
  directory is on `PATH`. For a source checkout, run `npm run build` and `npm link`.
188
216
  - **A preview asks for `--approve-risk`:** read the reported scripts, domains,
189
217
  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.3",
3
+ "version": "0.5.5",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",