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,475 @@
1
+ export const AGENT_HEALTH_POLICY_VERSION = "p16-07-v1";
2
+ export const AGENT_HEALTH_DIMENSION_CAPS = {
3
+ "provenance-license": 14,
4
+ "static-risk": 12,
5
+ drift: 14,
6
+ duplicates: 8,
7
+ staleness: 8,
8
+ "active-set-capacity": 10,
9
+ compatibility: 10,
10
+ benchmarks: 8,
11
+ "local-outcomes": 8,
12
+ recoverability: 8,
13
+ };
14
+ const DIMENSION_LABELS = {
15
+ "provenance-license": "Provenance and license",
16
+ "static-risk": "Static risk",
17
+ drift: "Managed-file drift",
18
+ duplicates: "Duplicate skills",
19
+ staleness: "Source freshness",
20
+ "active-set-capacity": "Active-set capacity",
21
+ compatibility: "Agent compatibility",
22
+ benchmarks: "Benchmark evidence",
23
+ "local-outcomes": "Local outcomes",
24
+ recoverability: "Recoverability",
25
+ };
26
+ const DIMENSION_ORDER = Object.keys(AGENT_HEALTH_DIMENSION_CAPS);
27
+ function round(value) {
28
+ return Number(value.toFixed(2));
29
+ }
30
+ function clamp(value, minimum = 0, maximum = 1) {
31
+ return Math.max(minimum, Math.min(maximum, value));
32
+ }
33
+ function count(value, label) {
34
+ if (!Number.isSafeInteger(value) || value < 0)
35
+ throw new Error(`${label} must be a non-negative safe integer`);
36
+ return value;
37
+ }
38
+ function assertUnique(values, label) {
39
+ if (new Set(values).size !== values.length)
40
+ throw new Error(`${label} contains duplicate values`);
41
+ }
42
+ function statusFor(contribution, cap, known) {
43
+ if (!known)
44
+ return "unknown";
45
+ const ratio = contribution / cap;
46
+ if (ratio >= 0.8)
47
+ return "strong";
48
+ if (ratio >= 0.4)
49
+ return "attention";
50
+ return "critical";
51
+ }
52
+ function dimension(id, ratio, known, evidence, uncertainty, remediation) {
53
+ const cap = AGENT_HEALTH_DIMENSION_CAPS[id];
54
+ const contribution = round(cap * clamp(ratio));
55
+ return {
56
+ id,
57
+ label: DIMENSION_LABELS[id],
58
+ cap,
59
+ contribution,
60
+ status: statusFor(contribution, cap, known),
61
+ evidence: evidence.length
62
+ ? evidence
63
+ : [
64
+ `No stored local evidence is available for ${DIMENSION_LABELS[id].toLowerCase()}.`,
65
+ ],
66
+ uncertainty: uncertainty.length
67
+ ? uncertainty
68
+ : [
69
+ "Local evidence is point-in-time and does not guarantee future behavior.",
70
+ ],
71
+ remediation: remediation.length
72
+ ? remediation
73
+ : ["Refresh this evidence after the agent configuration changes."],
74
+ };
75
+ }
76
+ function assertedLicense(value) {
77
+ return Boolean(value?.trim() && value.trim().toUpperCase() !== "NOASSERTION");
78
+ }
79
+ function provenanceAndLicense(packages) {
80
+ if (!packages.length)
81
+ return dimension("provenance-license", 0, false, [], [
82
+ "No installed package exists to match against pinned provenance or license metadata.",
83
+ ], [
84
+ "Install or adopt a package, then refresh reviewed provenance and SPDX license evidence.",
85
+ ]);
86
+ const provenanceValue = { verified: 1, managed: 0.5, unverified: 0 };
87
+ const verified = packages.filter((item) => item.provenance === "verified").length;
88
+ const managed = packages.filter((item) => item.provenance === "managed").length;
89
+ const asserted = packages.filter((item) => assertedLicense(item.license)).length;
90
+ const unknownProvenance = packages.filter((item) => item.provenance === undefined || item.provenance === "unverified").length;
91
+ const unknownLicense = packages.length - asserted;
92
+ const ratio = packages.reduce((total, item) => total +
93
+ ((item.provenance ? provenanceValue[item.provenance] : 0) +
94
+ (assertedLicense(item.license) ? 1 : 0)) /
95
+ 2, 0) / packages.length;
96
+ return dimension("provenance-license", ratio, true, [
97
+ `${verified}/${packages.length} package(s) have pinned reviewed provenance; ${managed} have Loadout ownership evidence only.`,
98
+ `${asserted}/${packages.length} package(s) have an asserted SPDX license; NOASSERTION and absence receive no credit.`,
99
+ ], [
100
+ unknownProvenance
101
+ ? `${unknownProvenance} package(s) lack verified pinned provenance.`
102
+ : "Pinned provenance proves source identity, not runtime safety or usefulness.",
103
+ unknownLicense
104
+ ? `${unknownLicense} package(s) lack an asserted license.`
105
+ : "License metadata is not a legal opinion and must remain attributable to its source.",
106
+ ], ratio === 1
107
+ ? [
108
+ "Re-verify pins and license metadata whenever a package revision changes.",
109
+ ]
110
+ : [
111
+ "Review unverified sources and resolve missing or NOASSERTION licenses before promotion.",
112
+ ]);
113
+ }
114
+ function staticRisk(packages) {
115
+ if (!packages.length)
116
+ return dimension("static-risk", 0, false, [], ["No package exists to assess statically."], [
117
+ "Run static inspection after packages are selected; do not infer safety from an empty profile.",
118
+ ]);
119
+ const assessed = packages.filter((item) => item.staticRisk !== undefined);
120
+ for (const item of assessed)
121
+ count(item.staticRisk.findingCount, `${item.packageId} static risk findings`);
122
+ const weights = { clear: 1, warning: 0.5, blocking: 0 };
123
+ const ratio = assessed.reduce((total, item) => total + weights[item.staticRisk.status], 0) / packages.length;
124
+ const blocking = assessed.filter((item) => item.staticRisk.status === "blocking").length;
125
+ const warnings = assessed.filter((item) => item.staticRisk.status === "warning").length;
126
+ const missing = packages.length - assessed.length;
127
+ return dimension("static-risk", ratio, assessed.length > 0, [
128
+ `${assessed.length}/${packages.length} package(s) have stored static analysis: ${blocking} blocking, ${warnings} warning, ${assessed.length - blocking - warnings} clear.`,
129
+ `${assessed.reduce((total, item) => total + item.staticRisk.findingCount, 0)} finding(s) are retained as review evidence.`,
130
+ ], [
131
+ missing
132
+ ? `${missing} package(s) have no stored static assessment and receive no credit.`
133
+ : "Static inspection does not execute code or prove runtime behavior.",
134
+ ], blocking
135
+ ? [
136
+ "Keep blocking packages quarantined until each finding is reviewed and explicitly resolved.",
137
+ ]
138
+ : warnings || missing
139
+ ? ["Review warnings and statically assess every unassessed package."]
140
+ : [
141
+ "Re-run static analysis after every source or configuration change.",
142
+ ]);
143
+ }
144
+ function driftScore(evidence) {
145
+ if (!evidence)
146
+ return dimension("drift", 0, false, [], ["Managed file and MCP fingerprints have not been checked."], ["Run a local managed-file and MCP fingerprint check."]);
147
+ const checkedFiles = count(evidence.checkedFiles, "checked files");
148
+ const driftedFiles = count(evidence.driftedFiles, "drifted files");
149
+ const checkedMcp = count(evidence.checkedMcpServers, "checked MCP servers");
150
+ const driftedMcp = count(evidence.driftedMcpServers, "drifted MCP servers");
151
+ if (driftedFiles > checkedFiles || driftedMcp > checkedMcp)
152
+ throw new Error("Drifted items cannot exceed checked items");
153
+ const checked = checkedFiles + checkedMcp;
154
+ const drifted = driftedFiles + driftedMcp;
155
+ return dimension("drift", checked ? (checked - drifted) / checked : 0, checked > 0, checked
156
+ ? [
157
+ `${checkedFiles} managed file(s) and ${checkedMcp} MCP server(s) checked; ${drifted} total drift finding(s).`,
158
+ ]
159
+ : [], [
160
+ checked
161
+ ? "Fingerprints show byte/config equality only; they do not measure behavior."
162
+ : "A completed check with zero managed items is not positive integrity evidence.",
163
+ ], drifted
164
+ ? [
165
+ "Review drift, then reinstall, resynchronize, remove, or explicitly adopt the changed content.",
166
+ ]
167
+ : [
168
+ "Repeat fingerprint checks after external tools or agents change managed paths.",
169
+ ]);
170
+ }
171
+ function duplicateScore(evidence) {
172
+ if (!evidence)
173
+ return dimension("duplicates", 0, false, [], [
174
+ "No local skill inventory has been scanned for within-agent duplicates.",
175
+ ], [
176
+ "Scan the active agent skill inventory and group normalized duplicate names.",
177
+ ]);
178
+ const scanned = count(evidence.scannedSkills, "scanned skills");
179
+ const groups = count(evidence.withinAgentGroups, "duplicate groups");
180
+ const duplicates = count(evidence.duplicateSkills, "duplicate skills");
181
+ if (duplicates > scanned)
182
+ throw new Error("Duplicate skills cannot exceed scanned skills");
183
+ if ((groups === 0) !== (duplicates === 0))
184
+ throw new Error("Duplicate groups and duplicate skills must agree on whether duplicates exist");
185
+ return dimension("duplicates", scanned ? (scanned - duplicates) / scanned : 0, scanned > 0, scanned
186
+ ? [
187
+ `${scanned} skill(s) scanned; ${groups} within-agent group(s) contain ${duplicates} duplicate skill entries.`,
188
+ ]
189
+ : [], [
190
+ scanned
191
+ ? "Name overlap is a conflict signal; it does not prove semantic equivalence."
192
+ : "An empty inventory provides no evidence that duplicate guidance is absent.",
193
+ ], duplicates
194
+ ? [
195
+ "Compare duplicate groups and disable or remove redundant active skills after review.",
196
+ ]
197
+ : ["Re-scan after installing, enabling, or renaming skills."]);
198
+ }
199
+ function staleness(packages) {
200
+ if (!packages.length)
201
+ return dimension("staleness", 0, false, [], ["No package activity metadata exists."], [
202
+ "Refresh source activity after packages are installed; empty does not mean fresh.",
203
+ ]);
204
+ const assessed = packages.filter((item) => item.freshness !== undefined);
205
+ const weights = { fresh: 1, aging: 0.5, stale: 0 };
206
+ const ratio = assessed.reduce((total, item) => total + weights[item.freshness.status], 0) / packages.length;
207
+ const counts = {
208
+ fresh: assessed.filter((item) => item.freshness.status === "fresh").length,
209
+ aging: assessed.filter((item) => item.freshness.status === "aging").length,
210
+ stale: assessed.filter((item) => item.freshness.status === "stale").length,
211
+ };
212
+ for (const item of assessed)
213
+ if (item.freshness.ageDays !== undefined)
214
+ count(item.freshness.ageDays, `${item.packageId} freshness age`);
215
+ const missing = packages.length - assessed.length;
216
+ return dimension("staleness", ratio, assessed.length > 0, [
217
+ `${assessed.length}/${packages.length} package(s) have stored activity evidence: ${counts.fresh} fresh, ${counts.aging} aging, ${counts.stale} stale.`,
218
+ ], [
219
+ missing
220
+ ? `${missing} package(s) have no activity observation and receive no freshness credit.`
221
+ : "Repository activity is a maintenance signal, not proof of quality or compatibility.",
222
+ ], counts.stale || missing
223
+ ? [
224
+ "Refresh missing activity and compare stale or archived packages with reviewed alternatives.",
225
+ ]
226
+ : [
227
+ "Refresh activity metadata periodically; aging thresholds remain disclosed heuristics.",
228
+ ]);
229
+ }
230
+ function activeSetCapacity(evidence) {
231
+ if (!evidence)
232
+ return dimension("active-set-capacity", 0, false, [], ["No active-set count or configured capacity was supplied."], [
233
+ "Scan the agent and compare active skills with the disclosed capacity policy.",
234
+ ]);
235
+ const active = count(evidence.active, "active skills");
236
+ const capacity = count(evidence.capacity, "active-set capacity");
237
+ const disabled = count(evidence.disabled, "disabled skills");
238
+ const quarantined = count(evidence.quarantined, "quarantined skills");
239
+ if (capacity === 0)
240
+ throw new Error("Active-set capacity must be greater than zero");
241
+ const ratio = active <= capacity ? 1 : capacity / active;
242
+ return dimension("active-set-capacity", ratio, true, [
243
+ `${active}/${capacity} active skill slot(s) used; ${disabled} disabled and ${quarantined} quarantined.`,
244
+ ], [
245
+ "Capacity is a transparent context-load heuristic, not a universal model limit.",
246
+ ], active > capacity
247
+ ? [
248
+ `Disable or consolidate at least ${active - capacity} active skill(s), prioritizing duplicates and weak evidence.`,
249
+ ]
250
+ : [
251
+ "Keep nonessential packages in the reviewed library and re-check capacity after activation changes.",
252
+ ]);
253
+ }
254
+ function compatibility(evidence) {
255
+ if (!evidence?.length)
256
+ return dimension("compatibility", 0, false, [], ["No installed component has stored adapter compatibility evidence."], ["Map every active component to the reviewed adapter capability matrix."]);
257
+ const weights = {
258
+ native: 1,
259
+ adapted: 0.75,
260
+ unsupported: 0,
261
+ unknown: 0,
262
+ };
263
+ const counts = {
264
+ native: evidence.filter((item) => item.compatibility === "native").length,
265
+ adapted: evidence.filter((item) => item.compatibility === "adapted").length,
266
+ unsupported: evidence.filter((item) => item.compatibility === "unsupported")
267
+ .length,
268
+ unknown: evidence.filter((item) => item.compatibility === "unknown").length,
269
+ };
270
+ const ratio = evidence.reduce((total, item) => total + weights[item.compatibility], 0) /
271
+ evidence.length;
272
+ return dimension("compatibility", ratio, true, [
273
+ `${evidence.length} component(s) evaluated: ${counts.native} native, ${counts.adapted} adapted, ${counts.unsupported} unsupported, ${counts.unknown} unknown.`,
274
+ ], [
275
+ counts.adapted
276
+ ? `${counts.adapted} adapted component(s) depend on bounded conversion/configuration behavior.`
277
+ : "Compatibility declares supported layout/configuration behavior, not runtime usefulness.",
278
+ ...(counts.unknown
279
+ ? [`${counts.unknown} unknown component(s) receive no credit.`]
280
+ : []),
281
+ ], counts.unsupported || counts.unknown
282
+ ? [
283
+ "Disable unsupported components and gather official path/config evidence before adding adapter support.",
284
+ ]
285
+ : ["Re-run compatibility checks after agent or adapter upgrades."]);
286
+ }
287
+ function benchmarks(packages) {
288
+ if (!packages.length)
289
+ return dimension("benchmarks", 0, false, [], ["No package exists to benchmark."], [
290
+ "Store reproducible package/task benchmark results after selecting packages.",
291
+ ]);
292
+ const assessed = [];
293
+ let passed = 0;
294
+ let failed = 0;
295
+ const evidenceIds = [];
296
+ for (const item of packages) {
297
+ if (!item.benchmark)
298
+ continue;
299
+ const packagePassed = count(item.benchmark.passed, `${item.packageId} passed benchmarks`);
300
+ const packageFailed = count(item.benchmark.failed, `${item.packageId} failed benchmarks`);
301
+ assertUnique(item.benchmark.evidenceIds, `${item.packageId} benchmark evidence`);
302
+ if (item.benchmark.evidenceIds.some((id) => !id.trim()))
303
+ throw new Error(`${item.packageId} benchmark evidence ids must not be empty`);
304
+ if (packagePassed + packageFailed === 0 ||
305
+ item.benchmark.evidenceIds.length === 0)
306
+ continue;
307
+ assessed.push(item);
308
+ passed += packagePassed;
309
+ failed += packageFailed;
310
+ evidenceIds.push(...item.benchmark.evidenceIds);
311
+ }
312
+ assertUnique(evidenceIds, "benchmark evidence ids");
313
+ const runs = passed + failed;
314
+ const coverage = assessed.length / packages.length;
315
+ const ratio = runs ? coverage * (passed / runs) : 0;
316
+ const missing = packages.length - assessed.length;
317
+ return dimension("benchmarks", ratio, runs > 0, runs
318
+ ? [
319
+ `${passed}/${runs} stored benchmark run(s) passed across ${assessed.length}/${packages.length} package(s); ${evidenceIds.length} evidence id(s).`,
320
+ ]
321
+ : [], [
322
+ missing
323
+ ? `${missing} package(s) have no benchmark run and receive no credit.`
324
+ : "Stored benchmarks cover declared fixtures only and do not prove universal model quality.",
325
+ ], failed || missing
326
+ ? [
327
+ "Run reproducible agent/task fixtures for missing packages and investigate every failure before promotion.",
328
+ ]
329
+ : ["Re-run the same fixtures after package, model, or agent changes."]);
330
+ }
331
+ const OUTCOME_VALUES = {
332
+ accept: 1,
333
+ success: 1,
334
+ activation: 0.6,
335
+ disable: 0.25,
336
+ reject: 0,
337
+ failure: 0,
338
+ rollback: 0,
339
+ };
340
+ const FULL_OUTCOME_SAMPLE = 10;
341
+ function localOutcomes(agent, evidence) {
342
+ const events = (evidence ?? []).filter((event) => event.agent === agent);
343
+ if (!events.length)
344
+ return dimension("local-outcomes", 0, false, [], [
345
+ "No local-only outcome event exists for this agent; absence is not success.",
346
+ ], [
347
+ "Record accept/reject, success/failure, activation/disable, and rollback outcomes without project content.",
348
+ ]);
349
+ assertUnique(events.map((event) => event.id), "local outcome event ids");
350
+ const quality = events.reduce((total, event) => total + OUTCOME_VALUES[event.result], 0) /
351
+ events.length;
352
+ const sampleConfidence = Math.min(1, events.length / FULL_OUTCOME_SAMPLE);
353
+ const negative = events.filter((event) => ["reject", "failure", "rollback"].includes(event.result)).length;
354
+ return dimension("local-outcomes", quality * sampleConfidence, true, [
355
+ `${events.length} local-only event(s) for ${agent}; ${negative} rejection/failure/rollback outcome(s); sample confidence ${round(sampleConfidence * 100)}%.`,
356
+ ], [
357
+ events.length < FULL_OUTCOME_SAMPLE
358
+ ? `${FULL_OUTCOME_SAMPLE - events.length} more scoped event(s) are required for full sample confidence.`
359
+ : "Outcomes are user/device-specific and do not establish causal improvement.",
360
+ ], negative
361
+ ? [
362
+ "Inspect packages associated with negative outcomes; compare, disable, or roll back when evidence supports it.",
363
+ ]
364
+ : [
365
+ "Keep recording privacy-safe outcomes across task families to reduce sampling uncertainty.",
366
+ ]);
367
+ }
368
+ function recoverability(evidence) {
369
+ if (!evidence)
370
+ return dimension("recoverability", 0, false, [], ["No local transaction/snapshot recovery check is stored."], [
371
+ "Verify transaction coverage, readable snapshots, and pending-journal recovery.",
372
+ ]);
373
+ const protectedMutations = count(evidence.protectedMutations, "protected mutations");
374
+ const recoverableMutations = count(evidence.recoverableMutations, "recoverable mutations");
375
+ const readableSnapshots = count(evidence.readableSnapshots, "readable snapshots");
376
+ const corruptSnapshots = count(evidence.corruptSnapshots, "corrupt snapshots");
377
+ const pending = count(evidence.pendingTransactions, "pending transactions");
378
+ if (recoverableMutations > protectedMutations)
379
+ throw new Error("Recoverable mutations cannot exceed protected mutations");
380
+ const snapshots = readableSnapshots + corruptSnapshots;
381
+ const mutationRatio = protectedMutations
382
+ ? recoverableMutations / protectedMutations
383
+ : 0;
384
+ const snapshotRatio = snapshots ? readableSnapshots / snapshots : 0;
385
+ const pendingPenalty = 1 / (1 + pending);
386
+ const ratio = (mutationRatio * 0.6 + snapshotRatio * 0.4) * pendingPenalty;
387
+ const known = protectedMutations > 0 || snapshots > 0 || pending > 0;
388
+ return dimension("recoverability", ratio, known, known
389
+ ? [
390
+ `${recoverableMutations}/${protectedMutations} mutation class(es) are transactionally recoverable; ${readableSnapshots}/${snapshots} snapshot(s) readable; ${pending} pending transaction(s).`,
391
+ ]
392
+ : [], [
393
+ protectedMutations
394
+ ? "Snapshot readability proves parseability, not that every external side effect can be reversed."
395
+ : "No protected mutation coverage is stored and receives no credit.",
396
+ ...(snapshots
397
+ ? []
398
+ : ["No readable snapshot evidence is stored and receives no credit."]),
399
+ ], recoverableMutations < protectedMutations || corruptSnapshots || pending
400
+ ? [
401
+ "Repair corrupt snapshots, recover pending journals, and move every mutation class behind the durable transaction boundary.",
402
+ ]
403
+ : [
404
+ "Periodically exercise rollback and interrupted-transaction recovery in a disposable environment.",
405
+ ]);
406
+ }
407
+ function rating(score, knownDimensions, dimensions) {
408
+ if (knownDimensions === 0)
409
+ return "unknown";
410
+ if (score < 40)
411
+ return "critical";
412
+ if (dimensions.some((item) => item.status === "critical"))
413
+ return "attention";
414
+ if (score >= 90 && dimensions.every((item) => item.status === "strong"))
415
+ return "excellent";
416
+ if (score >= 70)
417
+ return "good";
418
+ return "attention";
419
+ }
420
+ function validateEvidence(evidence) {
421
+ assertUnique(evidence.packages.map((item) => item.packageId), "agent health package ids");
422
+ if (evidence.packages.some((item) => !item.packageId.trim()))
423
+ throw new Error("Agent health package ids must not be empty");
424
+ if (evidence.asOf && !Number.isFinite(Date.parse(evidence.asOf)))
425
+ throw new Error("Agent health observation time is invalid");
426
+ }
427
+ /**
428
+ * Score only supplied stored/local evidence. This function performs no I/O,
429
+ * uses no popularity proxy, and never turns missing evidence into a pass.
430
+ */
431
+ export function buildAgentHealthScore(evidence) {
432
+ validateEvidence(evidence);
433
+ const byId = {
434
+ "provenance-license": provenanceAndLicense(evidence.packages),
435
+ "static-risk": staticRisk(evidence.packages),
436
+ drift: driftScore(evidence.drift),
437
+ duplicates: duplicateScore(evidence.duplicates),
438
+ staleness: staleness(evidence.packages),
439
+ "active-set-capacity": activeSetCapacity(evidence.activeSet),
440
+ compatibility: compatibility(evidence.compatibility),
441
+ benchmarks: benchmarks(evidence.packages),
442
+ "local-outcomes": localOutcomes(evidence.agent, evidence.outcomes),
443
+ recoverability: recoverability(evidence.recoverability),
444
+ };
445
+ const dimensions = DIMENSION_ORDER.map((id) => byId[id]);
446
+ const knownDimensions = dimensions.filter((item) => item.status !== "unknown").length;
447
+ const score = round(dimensions.reduce((total, item) => total + item.contribution, 0));
448
+ return {
449
+ schemaVersion: 1,
450
+ policyVersion: AGENT_HEALTH_POLICY_VERSION,
451
+ agent: evidence.agent,
452
+ ...(evidence.asOf ? { asOf: evidence.asOf } : {}),
453
+ score,
454
+ maximumScore: 100,
455
+ rating: rating(score, knownDimensions, dimensions),
456
+ evidenceCoverage: round((knownDimensions / dimensions.length) * 100),
457
+ knownDimensions,
458
+ dimensions,
459
+ limitations: [
460
+ "This deterministic score summarizes stored local evidence; it does not call a model, execute package code, or prove task quality.",
461
+ "Dimensions are independently capped so one strong signal cannot hide missing provenance, safety, integrity, compatibility, outcome, or recovery evidence.",
462
+ ],
463
+ };
464
+ }
465
+ export function formatAgentHealthScore(score) {
466
+ const lines = [
467
+ `Agent Health Score: ${score.score}/${score.maximumScore} (${score.rating}; evidence coverage ${score.evidenceCoverage}%)`,
468
+ `Agent: ${score.agent}${score.asOf ? ` · observed ${score.asOf}` : ""}`,
469
+ ];
470
+ for (const item of score.dimensions) {
471
+ lines.push(`${item.label}: ${item.contribution}/${item.cap} (${item.status})`, ...item.evidence.map((entry) => ` Evidence: ${entry}`), ...item.uncertainty.map((entry) => ` Uncertainty: ${entry}`), ...item.remediation.map((entry) => ` Remediation: ${entry}`));
472
+ }
473
+ lines.push(...score.limitations.map((item) => `Limitation: ${item}`));
474
+ return lines.join("\n");
475
+ }
@@ -0,0 +1,152 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { adapterCapabilities, agentComponentDirectory } from "./adapters.js";
4
+ const COMPONENT_TYPES = [
5
+ "skill",
6
+ "rule",
7
+ "command",
8
+ "agent",
9
+ "mcp",
10
+ "plugin",
11
+ "root",
12
+ ];
13
+ const MAX_DEPTH = 4;
14
+ async function scanDirectory(root) {
15
+ try {
16
+ const info = await lstat(root);
17
+ if (info.isSymbolicLink())
18
+ return {
19
+ exists: true,
20
+ entries: [{ path: ".", kind: "symlink" }],
21
+ warnings: [
22
+ `Refusing to inspect symlinked component directory: ${root}`,
23
+ ],
24
+ };
25
+ if (!info.isDirectory())
26
+ return {
27
+ exists: true,
28
+ entries: [],
29
+ warnings: [
30
+ `Expected a directory but found a non-directory component path: ${root}`,
31
+ ],
32
+ };
33
+ }
34
+ catch {
35
+ return { exists: false, entries: [], warnings: [] };
36
+ }
37
+ const entries = [];
38
+ const warnings = [];
39
+ async function visit(directory, depth) {
40
+ let children;
41
+ try {
42
+ children = await readdir(directory, { withFileTypes: true });
43
+ }
44
+ catch {
45
+ warnings.push(`Could not read component directory: ${directory}`);
46
+ return;
47
+ }
48
+ for (const child of children) {
49
+ const path = relative(root, join(directory, child.name))
50
+ .split(sep)
51
+ .join("/");
52
+ if (child.isSymbolicLink()) {
53
+ entries.push({ path, kind: "symlink" });
54
+ warnings.push(`Symlink was not followed: ${join(directory, child.name)}`);
55
+ }
56
+ else if (child.isDirectory()) {
57
+ entries.push({ path, kind: "directory" });
58
+ if (depth < MAX_DEPTH)
59
+ await visit(join(directory, child.name), depth + 1);
60
+ }
61
+ else if (child.isFile())
62
+ entries.push({ path, kind: "file" });
63
+ }
64
+ }
65
+ await visit(root, 0);
66
+ return {
67
+ exists: true,
68
+ entries: entries.sort((a, b) => a.path.localeCompare(b.path)),
69
+ warnings,
70
+ };
71
+ }
72
+ function unscannedInventory(agent, type) {
73
+ const compatibility = adapterCapabilities(agent.id).components[type];
74
+ if (compatibility === "unsupported") {
75
+ return {
76
+ type,
77
+ compatibility,
78
+ scanned: false,
79
+ entries: [],
80
+ note: "Unsupported by this adapter; Loadout will not install or inspect it.",
81
+ };
82
+ }
83
+ if (type === "mcp") {
84
+ return {
85
+ type,
86
+ compatibility,
87
+ scanned: false,
88
+ entries: [],
89
+ note: "MCP configuration is explicit and config-file scoped; no agent-wide MCP directory is assumed.",
90
+ };
91
+ }
92
+ if (type === "plugin") {
93
+ return {
94
+ type,
95
+ compatibility,
96
+ scanned: false,
97
+ entries: [],
98
+ note: "Plugin manifests are normalized during package inspection; plugin runtime behavior is not inspected or converted.",
99
+ };
100
+ }
101
+ return {
102
+ type,
103
+ compatibility,
104
+ scanned: false,
105
+ entries: [],
106
+ note: "Root exports are manifest-scoped, not stored in an agent-owned Loadout directory.",
107
+ };
108
+ }
109
+ /**
110
+ * Read agent-owned component directories without executing agent code or
111
+ * following symlinks. Only locations declared by the adapter matrix are
112
+ * scanned; unsupported capabilities stay explicitly unscanned.
113
+ */
114
+ export async function inspectAgent(agent) {
115
+ const components = [];
116
+ const warnings = [];
117
+ for (const type of COMPONENT_TYPES) {
118
+ const directory = agentComponentDirectory(agent, type);
119
+ if (!directory) {
120
+ components.push(unscannedInventory(agent, type));
121
+ continue;
122
+ }
123
+ const result = await scanDirectory(directory);
124
+ warnings.push(...result.warnings);
125
+ components.push({
126
+ type,
127
+ compatibility: adapterCapabilities(agent.id).components[type],
128
+ scanned: true,
129
+ directory,
130
+ directoryExists: result.exists,
131
+ entries: result.entries,
132
+ });
133
+ }
134
+ return { agent, components, warnings };
135
+ }
136
+ export async function inspectAgents(agents) {
137
+ return Promise.all(agents.map(inspectAgent));
138
+ }
139
+ export function formatAgentInventory(inventory) {
140
+ const lines = [
141
+ `${inventory.agent.installed ? "✓" : "○"} ${inventory.agent.displayName}`,
142
+ ];
143
+ for (const component of inventory.components) {
144
+ const state = component.scanned
145
+ ? `${component.directoryExists ? `${component.entries.length} filesystem item(s)` : "not created"} — ${component.directory}`
146
+ : (component.note ?? "not inspected");
147
+ lines.push(` ${component.type}: ${component.compatibility}; ${state}`);
148
+ }
149
+ for (const warning of inventory.warnings)
150
+ lines.push(` ! ${warning}`);
151
+ return lines.join("\n");
152
+ }