loadout-ai 0.5.3 → 0.5.4

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,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.4 - 2026-07-21
6
+
7
+ ### Added
8
+
9
+ - Add `loadout reconcile` to compare complete existing skill trees with pinned
10
+ catalog units, group identical cross-agent mirrors, adopt exact matches without
11
+ rewriting them, and preview unambiguous outdated replacements separately.
12
+ - Add immutable, licensed catalog records for the official Cloudflare Skills
13
+ collection and Humanizer, with direct upstream credit links.
14
+
15
+ ### Changed
16
+
17
+ - Index safe skill siblings even when another unit in the same collection is
18
+ quarantined; repository metadata such as `.git`, caches, and `node_modules` is
19
+ excluded from the distributable security tree.
20
+ - Scan both Codex's canonical `~/.agents/skills` directory and its current
21
+ `~/.codex/skills` compatibility directory while excluding host-bundled `.system`
22
+ skills.
23
+
24
+ ### Fixed
25
+
26
+ - Preserve the exact existing host path and exact adopted skill unit during later
27
+ repository updates instead of broadening a collection or moving a Codex skill.
28
+ - Verify every outdated reconciliation copy byte-for-byte before ownership state is
29
+ committed, with one rollback snapshot for the grouped transaction.
30
+
5
31
  ## 0.5.3 - 2026-07-21
6
32
 
7
33
  ### Fixed
package/MASTER_PLAN.md CHANGED
@@ -294,6 +294,30 @@ 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.
297
321
 
298
322
  ### Release 0.3 lifecycle hardening
299
323
 
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.4`; 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.4
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.4";
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",
@@ -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
  }
@@ -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;
@@ -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);
@@ -265,12 +265,33 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
265
265
  .join(" ")}`);
266
266
  }
267
267
  const agents = installedAgents(await (runtime.detectAgents ?? detectAgents)(), record.targetAgents);
268
- const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
268
+ let plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
269
269
  ? {
270
270
  include: (skill) => units.some((unitId) => unitId.toLowerCase() === skill.name?.toLowerCase() ||
271
271
  unitId.toLowerCase() === skill.targetName.toLowerCase()),
272
272
  }
273
273
  : {});
274
+ if (units.length) {
275
+ const activeTargets = new Map((state.activations ?? [])
276
+ .filter((activation) => activation.packageId === packageId &&
277
+ activation.installationState === "installed" &&
278
+ activation.activationState === "active" &&
279
+ activation.unitId)
280
+ .flatMap((activation) => activation.targets.map((target) => [
281
+ `${activation.agent}\0${activation.unitId.toLowerCase()}`,
282
+ target.activePath,
283
+ ])));
284
+ plan = {
285
+ ...plan,
286
+ files: plan.files.map((file) => {
287
+ const unitId = file.skillName ?? basename(file.target);
288
+ const target = file.targetAgent
289
+ ? activeTargets.get(`${file.targetAgent}\0${unitId.toLowerCase()}`)
290
+ : undefined;
291
+ return target ? { ...file, target } : file;
292
+ }),
293
+ };
294
+ }
274
295
  if (units.length) {
275
296
  const plannedUnits = [
276
297
  ...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.4` 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.4",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",