loadout-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,239 @@
1
+ import { basename } from "node:path";
2
+ import { explainCatalogScore } from "./ranking.js";
3
+ const STOP_WORDS = new Set([
4
+ "a",
5
+ "an",
6
+ "and",
7
+ "as",
8
+ "at",
9
+ "by",
10
+ "for",
11
+ "from",
12
+ "in",
13
+ "into",
14
+ "is",
15
+ "it",
16
+ "of",
17
+ "on",
18
+ "or",
19
+ "the",
20
+ "this",
21
+ "to",
22
+ "tool",
23
+ "use",
24
+ "using",
25
+ "with",
26
+ "workflow",
27
+ ]);
28
+ function normalized(value) {
29
+ return value.trim().toLowerCase();
30
+ }
31
+ function terms(name, description) {
32
+ return new Set(`${name} ${description ?? ""}`
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9+#.-]+/g, " ")
35
+ .split(/\s+/)
36
+ .map((value) => value.replace(/^[.-]+|[.-]+$/g, ""))
37
+ .filter((value) => value.length > 1 && !STOP_WORDS.has(value)));
38
+ }
39
+ function jaccard(left, right) {
40
+ const union = new Set([...left, ...right]);
41
+ if (!union.size)
42
+ return 0;
43
+ const intersection = [...left].filter((value) => right.has(value)).length;
44
+ return intersection / union.size;
45
+ }
46
+ function relationship(subject, candidate) {
47
+ if (subject.fingerprint === candidate.fingerprint)
48
+ return { relationship: "exact-copy", similarity: 1 };
49
+ if (normalized(subject.name) === normalized(candidate.skillName))
50
+ return { relationship: "divergent-same-name", similarity: 1 };
51
+ const similarity = jaccard(terms(subject.name, subject.description), terms(candidate.skillName, candidate.description));
52
+ if (similarity >= 0.3)
53
+ return { relationship: "overlapping-capability", similarity };
54
+ const subjectCategory = subject.catalog?.category;
55
+ if (subjectCategory && subjectCategory === candidate.category)
56
+ return { relationship: "same-category-candidate", similarity };
57
+ return { similarity };
58
+ }
59
+ function installedMatches(query, report, agent) {
60
+ const value = normalized(query);
61
+ const scoped = agent
62
+ ? report.skills.filter((skill) => skill.agent === agent)
63
+ : report.skills;
64
+ const exact = scoped.filter((skill) => normalized(skill.name) === value ||
65
+ normalized(basename(skill.path)) === value ||
66
+ skill.provenance.candidates.some((candidate) => candidate.packageId === value));
67
+ if (exact.length)
68
+ return exact;
69
+ return scoped.filter((skill) => normalized(skill.name).includes(value) ||
70
+ normalized(basename(skill.path)).includes(value));
71
+ }
72
+ function selectInstalledSubject(query, matches) {
73
+ if (!matches.length)
74
+ return { mirrors: [] };
75
+ const fingerprints = new Set(matches.map((skill) => skill.fingerprint));
76
+ if (fingerprints.size > 1)
77
+ throw new Error(`Several divergent installed skills match '${query}'. Re-run with --agent <id> or use an exact skill directory name: ${matches.map((skill) => `${skill.agent}:${basename(skill.path)}`).join(", ")}`);
78
+ const selected = matches[0];
79
+ return {
80
+ subject: {
81
+ source: "installed",
82
+ name: selected.name,
83
+ ...(selected.description ? { description: selected.description } : {}),
84
+ fingerprint: selected.fingerprint,
85
+ agent: selected.agent,
86
+ path: selected.path,
87
+ provenance: selected.provenance,
88
+ ...(selected.provenance.candidates[0]
89
+ ? { catalog: selected.provenance.candidates[0] }
90
+ : {}),
91
+ },
92
+ mirrors: matches
93
+ .filter((skill) => skill !== selected)
94
+ .map((skill) => ({ agent: skill.agent, path: skill.path })),
95
+ };
96
+ }
97
+ function selectCatalogSubject(query, records) {
98
+ const value = normalized(query);
99
+ const matches = records.filter((record) => record.packageId === value || normalized(record.skillName) === value);
100
+ if (!matches.length)
101
+ return undefined;
102
+ if (matches.length > 1 && matches.every((item) => item.packageId === value))
103
+ throw new Error(`Catalog package '${query}' contains ${matches.length} skills. Compare one by skill name: ${matches
104
+ .slice(0, 20)
105
+ .map((item) => item.skillName)
106
+ .join(", ")}`);
107
+ if (matches.length > 1)
108
+ throw new Error(`Several reviewed catalog skills are named '${query}'. Use a package id or inspect JSON output to disambiguate: ${[...new Set(matches.map((item) => item.packageId))].join(", ")}`);
109
+ const selected = matches[0];
110
+ return {
111
+ source: "catalog",
112
+ name: selected.skillName,
113
+ ...(selected.description ? { description: selected.description } : {}),
114
+ fingerprint: selected.fingerprint,
115
+ catalog: selected,
116
+ };
117
+ }
118
+ function evidenceFor(relationshipValue, similarity, candidate, pkg) {
119
+ const relationshipEvidence = {
120
+ "exact-copy": "The SKILL.md instruction fingerprint is identical.",
121
+ "divergent-same-name": "The normalized name matches but the instructions differ; this is not proof of equivalence or improvement.",
122
+ "overlapping-capability": `Name/description token overlap is ${(similarity * 100).toFixed(0)}%; task evaluation is still required.`,
123
+ "same-category-candidate": "The catalog category matches, but textual overlap is weak; treat this as exploration rather than a replacement.",
124
+ };
125
+ return [
126
+ relationshipEvidence[relationshipValue],
127
+ `${candidate.tier} catalog tier; reviewed commit ${candidate.commit.slice(0, 12)}; license ${candidate.license ?? "unverified"}.`,
128
+ pkg
129
+ ? `Evidence score ${explainCatalogScore(pkg).score}/100 orders catalog evidence only; it is not a universal quality score.`
130
+ : "No current catalog metadata was available for an evidence score.",
131
+ ];
132
+ }
133
+ function evidenceDimensionsFor(pkg) {
134
+ const explanation = pkg ? explainCatalogScore(pkg) : undefined;
135
+ const contribution = (factor) => explanation?.contributions.find((item) => item.factor === factor)
136
+ ?.evidence ?? "No current catalog evidence is available.";
137
+ return {
138
+ adoption: contribution("adoption"),
139
+ momentum: contribution("momentum"),
140
+ maintenance: contribution("maintenance"),
141
+ compatibility: contribution("compatibility"),
142
+ permissions: "No declarative permission manifest exists for this skill; preview and safety inspection are required before install.",
143
+ evaluation: "No skill-specific model-output benchmark is stored yet; relationship evidence is not an outcome evaluation.",
144
+ };
145
+ }
146
+ function recommendationFor(subject, alternatives) {
147
+ if (subject.source === "catalog")
148
+ return alternatives.length
149
+ ? "This is a reviewed catalog skill, not an installed replacement decision. Compare the related candidates on your task before choosing what to install."
150
+ : "This is a reviewed catalog skill with no related candidate strong enough to show. Inspect its evidence and preview its files before deciding whether to install it.";
151
+ if (subject.provenance?.kind === "catalog-exact" ||
152
+ subject.provenance?.kind === "loadout-managed")
153
+ return "Keep the installed skill for now: its source is strongly attributable. Review alternatives only if a task-specific evaluation or maintenance signal demonstrates an advantage.";
154
+ const sameName = alternatives.find((item) => item.relationship === "divergent-same-name");
155
+ if (sameName)
156
+ return `Review ${sameName.packageDisplayName}/${sameName.skillName} head-to-head before replacing anything. It has the same name but different instructions, so Loadout cannot honestly call it better yet.`;
157
+ const overlapping = alternatives.find((item) => item.relationship === "overlapping-capability");
158
+ if (overlapping)
159
+ return `Explore ${overlapping.packageDisplayName}/${overlapping.skillName} as a possible alternative. Keep the current skill until category-specific evaluation or your explicit outcome supports a change.`;
160
+ return "No reviewed replacement has enough relationship evidence. Keep the current skill, record its source if known, and leave it marked unknown rather than guessing.";
161
+ }
162
+ export function compareSkill(query, inventory, records, catalog, options = {}) {
163
+ if (!query.trim())
164
+ throw new Error("Comparison query cannot be empty");
165
+ const installed = selectInstalledSubject(query, installedMatches(query, inventory, options.agent));
166
+ const subject = installed.subject ?? selectCatalogSubject(query, records);
167
+ if (!subject)
168
+ throw new Error(`No installed or reviewed catalog skill matches '${query}'. Run loadout scan --refresh-provenance or loadout search first.`);
169
+ const packages = new Map(catalog.map((pkg) => [pkg.id, pkg]));
170
+ const priority = {
171
+ "exact-copy": 4,
172
+ "divergent-same-name": 3,
173
+ "overlapping-capability": 2,
174
+ "same-category-candidate": 1,
175
+ };
176
+ const alternatives = records
177
+ .filter((candidate) => !(subject.catalog &&
178
+ candidate.packageId === subject.catalog.packageId &&
179
+ candidate.skillPath === subject.catalog.skillPath))
180
+ .map((candidate) => {
181
+ const relation = relationship(subject, candidate);
182
+ if (!relation.relationship)
183
+ return undefined;
184
+ const pkg = packages.get(candidate.packageId);
185
+ const explanation = pkg ? explainCatalogScore(pkg) : undefined;
186
+ return {
187
+ relationship: relation.relationship,
188
+ similarity: Number(relation.similarity.toFixed(4)),
189
+ ...candidate,
190
+ evidenceScore: explanation?.score ?? 0,
191
+ evidence: evidenceFor(relation.relationship, relation.similarity, candidate, pkg),
192
+ evidenceDimensions: evidenceDimensionsFor(pkg),
193
+ };
194
+ })
195
+ .filter((candidate) => candidate !== undefined)
196
+ .sort((left, right) => priority[right.relationship] - priority[left.relationship] ||
197
+ right.similarity - left.similarity ||
198
+ right.evidenceScore - left.evidenceScore ||
199
+ left.packageId.localeCompare(right.packageId))
200
+ .slice(0, Math.max(1, Math.min(options.limit ?? 10, 50)));
201
+ return {
202
+ query,
203
+ subject,
204
+ mirroredInstallations: installed.mirrors,
205
+ alternatives,
206
+ recommendation: recommendationFor(subject, alternatives),
207
+ uncertainty: "This comparison uses provenance, reviewed static evidence, maintenance/adoption metadata, and deterministic text relationships. It does not prove that one skill produces better model output on every task.",
208
+ ...(options.indexGeneratedAt
209
+ ? { catalogIndexGeneratedAt: options.indexGeneratedAt }
210
+ : {}),
211
+ catalogIndexFailures: options.failures ?? [],
212
+ };
213
+ }
214
+ export function formatSkillComparison(report) {
215
+ const subject = report.subject;
216
+ const lines = [
217
+ `Compare: ${subject.name}`,
218
+ `Subject: ${subject.source}${subject.agent ? ` on ${subject.agent}` : ""}${subject.path ? ` — ${subject.path}` : ""}`,
219
+ ];
220
+ if (subject.catalog)
221
+ lines.push(`Reviewed source: ${subject.catalog.packageDisplayName} · ${subject.catalog.repository}@${subject.catalog.commit.slice(0, 12)} · ${subject.catalog.tier} · license ${subject.catalog.license ?? "unverified"}`);
222
+ if (subject.provenance)
223
+ lines.push(`Provenance: ${subject.provenance.kind} (${subject.provenance.confidence})`, ...subject.provenance.evidence.map((item) => ` ${item}`));
224
+ if (report.mirroredInstallations.length)
225
+ lines.push(`Mirrors: ${report.mirroredInstallations.map((item) => `${item.agent}:${item.path}`).join(", ")}`);
226
+ lines.push("", "Reviewed alternatives:");
227
+ if (!report.alternatives.length)
228
+ lines.push(" No related reviewed candidate found.");
229
+ for (const alternative of report.alternatives) {
230
+ lines.push(` ${alternative.packageDisplayName}/${alternative.skillName} [${alternative.relationship}]`, ` ${alternative.repository}@${alternative.commit.slice(0, 12)} · ${alternative.tier} · evidence ${alternative.evidenceScore}/100`);
231
+ for (const evidence of alternative.evidence)
232
+ lines.push(` - ${evidence}`);
233
+ lines.push(` Adoption: ${alternative.evidenceDimensions.adoption}`, ` Momentum: ${alternative.evidenceDimensions.momentum}`, ` Maintenance: ${alternative.evidenceDimensions.maintenance}`, ` Compatibility: ${alternative.evidenceDimensions.compatibility}`, ` Permissions: ${alternative.evidenceDimensions.permissions}`, ` Evaluation: ${alternative.evidenceDimensions.evaluation}`);
234
+ }
235
+ lines.push("", `Recommendation: ${report.recommendation}`, `Uncertainty: ${report.uncertainty}`);
236
+ if (report.catalogIndexFailures.length)
237
+ lines.push(`Index warning: ${report.catalogIndexFailures.length} reviewed repository/repositories could not be indexed.`);
238
+ return lines.join("\n");
239
+ }
@@ -0,0 +1,193 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir } from "node:fs/promises";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+ import { readInstallState } from "./state.js";
5
+ const MAX_SCAN_DEPTH = 6;
6
+ export const RECOMMENDED_ACTIVE_SKILLS = 30;
7
+ function cleanFrontmatterValue(value) {
8
+ if (!value)
9
+ return undefined;
10
+ const trimmed = value.trim();
11
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
12
+ (trimmed.startsWith("'") && trimmed.endsWith("'")))
13
+ return trimmed.slice(1, -1);
14
+ return trimmed;
15
+ }
16
+ function isInside(root, candidate) {
17
+ const path = relative(resolve(root), resolve(candidate));
18
+ return path === "" || (!path.startsWith("..") && !path.startsWith(sep));
19
+ }
20
+ function owningPackage(skillRoot, agent, records, activations) {
21
+ const active = activations.find((record) => record.agent === agent &&
22
+ record.activationState === "active" &&
23
+ record.installationState === "installed" &&
24
+ record.targets.some((target) => isInside(target.activePath, skillRoot)));
25
+ if (active)
26
+ return active.packageId;
27
+ return records.find((record) => record.targetAgents.includes(agent) &&
28
+ !activations.some((activation) => activation.packageId === record.packageId &&
29
+ activation.agent === agent) &&
30
+ record.files.some((file) => isInside(skillRoot, file.path)))?.packageId;
31
+ }
32
+ async function scanAgentSkills(agent, records, activations) {
33
+ const root = resolve(agent.skillsDirectory);
34
+ const skills = [];
35
+ const warnings = [];
36
+ async function visit(directory, depth) {
37
+ let info;
38
+ try {
39
+ info = await lstat(directory);
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ if (info.isSymbolicLink()) {
45
+ warnings.push(`${agent.displayName}: symlinked skill path was reported but not followed: ${directory}`);
46
+ return;
47
+ }
48
+ if (!info.isDirectory())
49
+ return;
50
+ let children;
51
+ try {
52
+ children = await readdir(directory, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ warnings.push(`${agent.displayName}: could not read ${directory}`);
56
+ return;
57
+ }
58
+ const skillFile = children.find((entry) => entry.isFile() && entry.name === "SKILL.md");
59
+ if (skillFile) {
60
+ try {
61
+ const path = join(directory, "SKILL.md");
62
+ const content = await readFile(path, "utf8");
63
+ const packageId = owningPackage(directory, agent.id, records, activations);
64
+ const sourceHints = [
65
+ ...new Set([
66
+ ...content.matchAll(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]*[A-Za-z0-9_-])/g),
67
+ ].map((match) => match[1].replace(/\.git$/, ""))),
68
+ ].sort();
69
+ skills.push({
70
+ agent: agent.id,
71
+ agentDisplayName: agent.displayName,
72
+ name: cleanFrontmatterValue(content.match(/^name:\s*(.+)$/m)?.[1]) ??
73
+ directory.split(sep).at(-1) ??
74
+ "unnamed",
75
+ ...(cleanFrontmatterValue(content.match(/^description:\s*(.+)$/m)?.[1])
76
+ ? {
77
+ description: cleanFrontmatterValue(content.match(/^description:\s*(.+)$/m)?.[1]),
78
+ }
79
+ : {}),
80
+ path: directory,
81
+ fingerprint: createHash("sha256").update(content).digest("hex"),
82
+ managed: Boolean(packageId),
83
+ ...(packageId ? { packageId } : {}),
84
+ ...(sourceHints.length ? { sourceHints } : {}),
85
+ });
86
+ }
87
+ catch (error) {
88
+ warnings.push(`${agent.displayName}: could not inspect ${join(directory, "SKILL.md")}: ${error instanceof Error ? error.message : String(error)}`);
89
+ }
90
+ return;
91
+ }
92
+ if (depth >= MAX_SCAN_DEPTH) {
93
+ warnings.push(`${agent.displayName}: stopped scanning below depth ${MAX_SCAN_DEPTH}: ${directory}`);
94
+ return;
95
+ }
96
+ for (const child of children) {
97
+ if (child.name === ".git" ||
98
+ child.name === "node_modules" ||
99
+ child.name === ".cache")
100
+ continue;
101
+ if (child.isDirectory() || child.isSymbolicLink())
102
+ await visit(join(directory, child.name), depth + 1);
103
+ }
104
+ }
105
+ await visit(root, 0);
106
+ return {
107
+ skills: skills.sort((left, right) => left.path.localeCompare(right.path)),
108
+ warnings,
109
+ };
110
+ }
111
+ function duplicateGroups(skills) {
112
+ const byName = new Map();
113
+ for (const skill of skills) {
114
+ const key = skill.name.trim().toLowerCase();
115
+ const items = byName.get(key) ?? [];
116
+ items.push(skill);
117
+ byName.set(key, items);
118
+ }
119
+ return [...byName.entries()]
120
+ .filter(([, entries]) => entries.length > 1)
121
+ .map(([name, entries]) => {
122
+ const agents = entries.map((entry) => entry.agent);
123
+ const withinAgent = new Set(agents).size < agents.length;
124
+ return {
125
+ name,
126
+ kind: withinAgent ? "within-agent" : "cross-agent-mirror",
127
+ entries: entries.map((entry) => ({
128
+ agent: entry.agent,
129
+ path: entry.path,
130
+ fingerprint: entry.fingerprint,
131
+ managed: entry.managed,
132
+ ...(entry.packageId ? { packageId: entry.packageId } : {}),
133
+ })),
134
+ };
135
+ })
136
+ .sort((left, right) => left.name.localeCompare(right.name));
137
+ }
138
+ /**
139
+ * Read existing agent skill directories without executing their instructions.
140
+ * Unmanaged means only "not installed by Loadout"; it is not a quality or
141
+ * safety verdict and no files are changed by this scan.
142
+ */
143
+ export async function scanInstalledSkills(agents) {
144
+ const state = await readInstallState();
145
+ const scans = await Promise.all(agents.map((agent) => scanAgentSkills(agent, state.installs, state.activations ?? [])));
146
+ const skills = scans.flatMap((scan) => scan.skills);
147
+ const warnings = scans.flatMap((scan) => scan.warnings);
148
+ const summaries = agents.map((agent, index) => {
149
+ const entries = scans[index].skills;
150
+ const managed = entries.filter((entry) => entry.managed).length;
151
+ return {
152
+ agent: agent.id,
153
+ displayName: agent.displayName,
154
+ directory: agent.skillsDirectory,
155
+ detected: agent.installed,
156
+ total: entries.length,
157
+ managed,
158
+ unmanaged: entries.length - managed,
159
+ overRecommendedLimit: entries.length > RECOMMENDED_ACTIVE_SKILLS,
160
+ };
161
+ });
162
+ for (const summary of summaries.filter((item) => item.overRecommendedLimit))
163
+ warnings.push(`${summary.displayName} exposes ${summary.total} skills; the recommended active set is at most ${RECOMMENDED_ACTIVE_SKILLS}.`);
164
+ const managed = skills.filter((entry) => entry.managed).length;
165
+ return {
166
+ generatedAt: new Date().toISOString(),
167
+ total: skills.length,
168
+ managed,
169
+ unmanaged: skills.length - managed,
170
+ uniqueNames: new Set(skills.map((entry) => entry.name.trim().toLowerCase()))
171
+ .size,
172
+ agents: summaries,
173
+ skills,
174
+ duplicates: duplicateGroups(skills),
175
+ warnings,
176
+ };
177
+ }
178
+ export function formatInstalledSkillInventory(report) {
179
+ const within = report.duplicates.filter((group) => group.kind === "within-agent").length;
180
+ const mirrors = report.duplicates.length - within;
181
+ const lines = [
182
+ `Skill inventory: ${report.total} skill(s), ${report.uniqueNames} unique name(s)`,
183
+ `Loadout-managed: ${report.managed}; unmanaged: ${report.unmanaged}`,
184
+ `Duplicate groups: ${within} within an agent; ${mirrors} cross-agent mirror(s)`,
185
+ "",
186
+ ];
187
+ for (const agent of report.agents)
188
+ lines.push(`${agent.detected ? "✓" : "○"} ${agent.displayName}: ${agent.total} skill(s) (${agent.managed} managed, ${agent.unmanaged} unmanaged) — ${agent.directory}`);
189
+ for (const warning of report.warnings)
190
+ lines.push(`! ${warning}`);
191
+ lines.push("", "Read-only scan complete. Unmanaged content was not changed or judged automatically.");
192
+ return lines.join("\n");
193
+ }