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,72 @@
1
+ import { inspectPackage } from "./package.js";
2
+ import { analyzeUpdateSafety } from "./safety.js";
3
+ function statusFor(findings) {
4
+ if (findings.some((finding) => finding.category === "secret" || finding.category === "instruction"))
5
+ return "blocked";
6
+ return findings.length ? "needs-review" : "ready";
7
+ }
8
+ /**
9
+ * Deterministically assesses static package evidence. It never starts an MCP
10
+ * server, executes a script, calls a model, or claims a performance benchmark.
11
+ */
12
+ export async function evaluatePackage(root) {
13
+ const inspection = await inspectPackage(root);
14
+ const safety = await analyzeUpdateSafety(undefined, root);
15
+ const sharedSafety = safety.findings.map((finding) => finding.message);
16
+ const names = inspection.skills.map((skill) => skill.name.toLowerCase());
17
+ const duplicateNames = names.filter((name, index) => names.indexOf(name) !== index);
18
+ const skillFindings = [
19
+ ...(duplicateNames.length
20
+ ? [`Duplicate skill names: ${[...new Set(duplicateNames)].join(", ")}`]
21
+ : []),
22
+ ...inspection.warnings,
23
+ ...sharedSafety,
24
+ ];
25
+ const mcpWarnings = inspection.mcpServers.flatMap((server) => server.warnings.map((warning) => `${server.name}: ${warning}`));
26
+ const invalidMcp = inspection.mcpServers.filter((server) => server.transport === "unknown");
27
+ const mcpFindings = [
28
+ ...mcpWarnings,
29
+ ...invalidMcp.map((server) => `${server.name}: no usable transport`),
30
+ ...sharedSafety,
31
+ ];
32
+ const invalidSkillEvidence = inspection.warnings.some((warning) => /skill (?:security validation failed|package|path|SKILL\.md)/i.test(warning));
33
+ const hasSkillEvidence = inspection.skills.length > 0 || invalidSkillEvidence;
34
+ return {
35
+ evaluatorVersion: 1,
36
+ root: inspection.root,
37
+ categories: [
38
+ {
39
+ category: "skills",
40
+ status: invalidSkillEvidence
41
+ ? "blocked"
42
+ : hasSkillEvidence
43
+ ? statusFor(safety.findings)
44
+ : "not-applicable",
45
+ findings: hasSkillEvidence ? skillFindings : [],
46
+ },
47
+ {
48
+ category: "mcp",
49
+ status: inspection.counts.manifests === 0
50
+ ? "not-applicable"
51
+ : invalidMcp.length ||
52
+ safety.findings.some((item) => item.category === "secret")
53
+ ? "blocked"
54
+ : mcpFindings.length
55
+ ? "needs-review"
56
+ : "ready",
57
+ findings: inspection.counts.manifests ? mcpFindings : [],
58
+ },
59
+ ],
60
+ uncertainty: "Static evidence only. This does not measure model quality, MCP uptime, permissions at runtime, or task performance.",
61
+ };
62
+ }
63
+ export function formatPackageEvaluation(evaluation) {
64
+ const lines = [`Evaluation: ${evaluation.root}`];
65
+ for (const category of evaluation.categories) {
66
+ lines.push(`${category.category}: ${category.status}`);
67
+ for (const finding of category.findings)
68
+ lines.push(` - ${finding}`);
69
+ }
70
+ lines.push(`Uncertainty: ${evaluation.uncertainty}`);
71
+ return lines.join("\n");
72
+ }
@@ -0,0 +1,140 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, utimes, } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { ensureDirectory } from "./paths.js";
5
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
6
+ function parseOwner(content) {
7
+ try {
8
+ const value = JSON.parse(content);
9
+ if (typeof value.token === "string" &&
10
+ typeof value.pid === "number" &&
11
+ Number.isInteger(value.pid) &&
12
+ value.pid > 0 &&
13
+ typeof value.acquiredAt === "string")
14
+ return value;
15
+ }
16
+ catch {
17
+ /* an incomplete owner file is handled only after it becomes stale */
18
+ }
19
+ return undefined;
20
+ }
21
+ function processIsAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ return !(error &&
28
+ typeof error === "object" &&
29
+ "code" in error &&
30
+ error.code === "ESRCH");
31
+ }
32
+ }
33
+ async function reclaimIfAbandoned(path, staleMs, maximumLeaseMs) {
34
+ const reclaim = `${path}.reclaim`;
35
+ try {
36
+ await mkdir(reclaim);
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ try {
42
+ let info;
43
+ let owner;
44
+ try {
45
+ [info, owner] = await Promise.all([
46
+ stat(path),
47
+ readFile(path, "utf8").then(parseOwner),
48
+ ]);
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ const heartbeatAge = Date.now() - info.mtimeMs;
54
+ // PID liveness protects a delayed event loop or short machine sleep. The
55
+ // hard ceiling prevents a reused PID from owning an orphan forever;
56
+ // healthy owners refresh mtime and never approach this ceiling.
57
+ if (owner && processIsAlive(owner.pid) && heartbeatAge <= maximumLeaseMs)
58
+ return;
59
+ if (!owner && heartbeatAge <= staleMs)
60
+ return;
61
+ const quarantine = `${path}.abandoned-${randomUUID()}`;
62
+ try {
63
+ await rename(path, quarantine);
64
+ await rm(quarantine, { force: true });
65
+ }
66
+ catch {
67
+ /* another contender changed the lock; retry through normal acquisition */
68
+ }
69
+ }
70
+ finally {
71
+ await rm(reclaim, { recursive: true, force: true });
72
+ }
73
+ }
74
+ /** Acquire a token-owned, heartbeating cross-process lock. */
75
+ export async function acquireFileLock(path, options = {}) {
76
+ const timeoutMs = options.timeoutMs ?? 5_000;
77
+ const staleMs = options.staleMs ?? 60_000;
78
+ const maximumLeaseMs = options.maximumLeaseMs ?? Math.max(24 * 60 * 60 * 1_000, staleMs * 10);
79
+ const started = Date.now();
80
+ await ensureDirectory(dirname(path));
81
+ while (true) {
82
+ const token = randomUUID();
83
+ try {
84
+ const handle = await open(path, "wx", 0o600);
85
+ try {
86
+ await handle.writeFile(`${JSON.stringify({ token, pid: process.pid, acquiredAt: new Date().toISOString() })}\n`);
87
+ }
88
+ catch (error) {
89
+ await handle.close().catch(() => undefined);
90
+ await rm(path, { force: true }).catch(() => undefined);
91
+ throw error;
92
+ }
93
+ await handle.close();
94
+ const heartbeat = setInterval(() => {
95
+ const now = new Date();
96
+ void utimes(path, now, now).catch(() => undefined);
97
+ }, Math.max(1_000, Math.floor(staleMs / 3)));
98
+ heartbeat.unref();
99
+ let released = false;
100
+ return {
101
+ path,
102
+ token,
103
+ async release() {
104
+ if (released)
105
+ return;
106
+ released = true;
107
+ clearInterval(heartbeat);
108
+ try {
109
+ const owner = parseOwner(await readFile(path, "utf8"));
110
+ if (owner?.token === token)
111
+ await rm(path, { force: true });
112
+ }
113
+ catch {
114
+ /* ownership changed or the lock was already removed */
115
+ }
116
+ },
117
+ };
118
+ }
119
+ catch (error) {
120
+ if (!error ||
121
+ typeof error !== "object" ||
122
+ !("code" in error) ||
123
+ error.code !== "EEXIST")
124
+ throw error;
125
+ }
126
+ await reclaimIfAbandoned(path, staleMs, maximumLeaseMs);
127
+ if (Date.now() - started >= timeoutMs)
128
+ throw new Error(`Timed out waiting for Loadout state lock: ${path}`);
129
+ await wait(50);
130
+ }
131
+ }
132
+ export async function withFileLock(path, operation, options = {}) {
133
+ const lock = await acquireFileLock(path, options);
134
+ try {
135
+ return await operation();
136
+ }
137
+ finally {
138
+ await lock.release();
139
+ }
140
+ }
@@ -0,0 +1,230 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { writeFileAtomically } from "./atomic-file.js";
5
+ import { loadEffectiveCatalog } from "./catalog.js";
6
+ import { ensureDirectory, loadoutHome } from "./paths.js";
7
+ import { readInstallState } from "./state.js";
8
+ import { buildUpdatePlan } from "./update.js";
9
+ const decisionsPath = () => join(loadoutHome(), "alert-decisions.json");
10
+ function alertId(packageId, kind, evidence) {
11
+ return `${packageId}-${kind}-${createHash("sha256")
12
+ .update(evidence)
13
+ .digest("hex")
14
+ .slice(0, 10)}`;
15
+ }
16
+ async function readDecisions() {
17
+ try {
18
+ const value = JSON.parse(await readFile(decisionsPath(), "utf8"));
19
+ if (value.schemaVersion !== 1 ||
20
+ !Array.isArray(value.ignored) ||
21
+ value.ignored.some((item) => !item ||
22
+ typeof item.id !== "string" ||
23
+ typeof item.ignoredAt !== "string") ||
24
+ (value.replacementPins !== undefined &&
25
+ (!Array.isArray(value.replacementPins) ||
26
+ value.replacementPins.some((item) => !item ||
27
+ typeof item.packageId !== "string" ||
28
+ typeof item.replacementPackageId !== "string" ||
29
+ typeof item.pinnedAt !== "string"))))
30
+ throw new Error("alert decision schema is invalid");
31
+ return { ...value, replacementPins: value.replacementPins ?? [] };
32
+ }
33
+ catch (error) {
34
+ if (error &&
35
+ typeof error === "object" &&
36
+ "code" in error &&
37
+ error.code === "ENOENT")
38
+ return { schemaVersion: 1, ignored: [], replacementPins: [] };
39
+ throw error;
40
+ }
41
+ }
42
+ function packageAlerts(state, catalog, updates, replacementEvidence, now) {
43
+ const catalogById = new Map(catalog.map((item) => [item.id, item]));
44
+ const catalogByRepository = new Map(catalog.map((item) => [item.repository.toLowerCase(), item]));
45
+ return state.installs.flatMap((install) => {
46
+ const pkg = catalogById.get(install.packageId) ??
47
+ (install.repository
48
+ ? catalogByRepository.get(install.repository.toLowerCase())
49
+ : undefined);
50
+ if (!pkg)
51
+ return [];
52
+ const alerts = [];
53
+ if (pkg.archived) {
54
+ const evidence = `${pkg.repository} is archived in current GitHub metadata`;
55
+ alerts.push({
56
+ id: alertId(install.packageId, "archived", evidence),
57
+ packageId: install.packageId,
58
+ kind: "archived",
59
+ severity: "critical",
60
+ message: "The installed source is archived.",
61
+ evidence: [evidence],
62
+ actions: [
63
+ `loadout compare ${install.packageId}`,
64
+ `loadout disable ${install.packageId}`,
65
+ ],
66
+ ignored: false,
67
+ });
68
+ }
69
+ if (pkg.pushedAt) {
70
+ const ageDays = Math.floor((now.getTime() - Date.parse(pkg.pushedAt)) / 86_400_000);
71
+ if (ageDays >= 365 && !pkg.archived) {
72
+ const evidence = `last code push was ${ageDays} days ago (${pkg.pushedAt})`;
73
+ alerts.push({
74
+ id: alertId(install.packageId, "materially-stale", evidence),
75
+ packageId: install.packageId,
76
+ kind: "materially-stale",
77
+ severity: "warning",
78
+ message: "The installed source is materially stale by the disclosed one-year heuristic.",
79
+ evidence: [evidence],
80
+ actions: [
81
+ `loadout compare ${install.packageId}`,
82
+ `loadout alert-ignore <alert-id>`,
83
+ ],
84
+ ignored: false,
85
+ });
86
+ }
87
+ }
88
+ if (install.resolvedCommit &&
89
+ pkg.source?.commit &&
90
+ install.resolvedCommit.toLowerCase() !== pkg.source.commit.toLowerCase()) {
91
+ const evidence = `installed ${install.resolvedCommit.slice(0, 12)}; reviewed catalog ${pkg.source.commit.slice(0, 12)}`;
92
+ alerts.push({
93
+ id: alertId(install.packageId, "reviewed-commit-changed", evidence),
94
+ packageId: install.packageId,
95
+ kind: "reviewed-commit-changed",
96
+ severity: "info",
97
+ message: "A different reviewed catalog commit is available.",
98
+ evidence: [evidence],
99
+ actions: [
100
+ `loadout update --package ${install.packageId}`,
101
+ `loadout compare ${install.packageId}`,
102
+ ],
103
+ ignored: false,
104
+ });
105
+ }
106
+ const update = updates.find((item) => item.packageId === install.packageId &&
107
+ item.status === "update-available" &&
108
+ item.approvalRequired);
109
+ if (update) {
110
+ const categories = [
111
+ ...new Set((update.safetyFindings ?? []).map((item) => item.category)),
112
+ ];
113
+ const evidence = `update safety findings: ${categories.join(", ") || "manual approval required"}`;
114
+ alerts.push({
115
+ id: alertId(install.packageId, "permission-expansion", evidence),
116
+ packageId: install.packageId,
117
+ kind: "permission-expansion",
118
+ severity: "warning",
119
+ message: "The available update adds or changes safety-sensitive behavior.",
120
+ evidence: [evidence],
121
+ actions: [`loadout update --package ${install.packageId}`],
122
+ ignored: false,
123
+ });
124
+ }
125
+ for (const evidence of replacementEvidence.filter((item) => item.installedPackageId === install.packageId && item.scoreDelta > 0)) {
126
+ const replacement = catalogById.get(evidence.replacementPackageId);
127
+ if (!replacement)
128
+ continue;
129
+ const proof = `signed evidence ${evidence.evidenceId} reports ${replacement.id} ahead by ${evidence.scoreDelta.toFixed(2)} points in its declared category`;
130
+ alerts.push({
131
+ id: alertId(install.packageId, "outperformed", proof),
132
+ packageId: install.packageId,
133
+ kind: "outperformed",
134
+ severity: "info",
135
+ message: `${replacement.displayName} has stronger reviewed evaluation evidence for a declared category.`,
136
+ evidence: [proof],
137
+ actions: [
138
+ `loadout compare ${install.packageId}`,
139
+ `loadout alert-pin ${install.packageId} ${replacement.id}`,
140
+ ],
141
+ ignored: false,
142
+ });
143
+ }
144
+ return alerts;
145
+ });
146
+ }
147
+ export async function buildFreshnessAlerts(options = {}) {
148
+ const [catalog, state, updates, decisions] = await Promise.all([
149
+ options.catalog ?? loadEffectiveCatalog(),
150
+ options.state ?? readInstallState(),
151
+ options.updates ??
152
+ (options.checkUpdates ? buildUpdatePlan() : Promise.resolve([])),
153
+ readDecisions(),
154
+ ]);
155
+ const ignored = new Set(decisions.ignored.map((item) => item.id));
156
+ return packageAlerts(state, catalog, updates, options.replacementEvidence ?? [], options.now ?? new Date()).map((alert) => ({ ...alert, ignored: ignored.has(alert.id) }));
157
+ }
158
+ export async function ignoreFreshnessAlert(id) {
159
+ if (!/^[a-zA-Z0-9._-]+$/.test(id))
160
+ throw new Error("Invalid alert id");
161
+ const decisions = await readDecisions();
162
+ decisions.ignored = [
163
+ ...decisions.ignored.filter((item) => item.id !== id),
164
+ { id, ignoredAt: new Date().toISOString() },
165
+ ].slice(-1000);
166
+ await ensureDirectory(dirname(decisionsPath()));
167
+ await writeFileAtomically(decisionsPath(), `${JSON.stringify(decisions, null, 2)}\n`);
168
+ }
169
+ function validPackageId(value) {
170
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(value))
171
+ throw new Error("Invalid package id");
172
+ }
173
+ /** Record an explicit local replacement preference; this never changes an active set. */
174
+ export async function pinReplacement(packageId, replacementPackageId, options = {}) {
175
+ validPackageId(packageId);
176
+ validPackageId(replacementPackageId);
177
+ if (packageId === replacementPackageId)
178
+ throw new Error("A package cannot replace itself");
179
+ const [catalog, state] = await Promise.all([
180
+ options.catalog ?? loadEffectiveCatalog(),
181
+ options.state ?? readInstallState(),
182
+ ]);
183
+ if (!state.installs.some((install) => install.packageId === packageId))
184
+ throw new Error(`Package '${packageId}' is not installed`);
185
+ const current = catalog.find((pkg) => pkg.id === packageId);
186
+ const replacement = catalog.find((pkg) => pkg.id === replacementPackageId);
187
+ if (!current)
188
+ throw new Error(`Installed package '${packageId}' is not in the reviewed catalog`);
189
+ if (!replacement)
190
+ throw new Error(`Replacement '${replacementPackageId}' is not in the reviewed catalog`);
191
+ if (current.category !== replacement.category)
192
+ throw new Error(`Replacement categories differ (${current.category} vs ${replacement.category}); compare related capabilities only`);
193
+ const decisions = await readDecisions();
194
+ decisions.replacementPins = [
195
+ ...decisions.replacementPins.filter((item) => item.packageId !== packageId),
196
+ { packageId, replacementPackageId, pinnedAt: new Date().toISOString() },
197
+ ];
198
+ await ensureDirectory(dirname(decisionsPath()));
199
+ await writeFileAtomically(decisionsPath(), `${JSON.stringify(decisions, null, 2)}\n`);
200
+ }
201
+ export async function unpinReplacement(packageId) {
202
+ validPackageId(packageId);
203
+ const decisions = await readDecisions();
204
+ const replacementPins = decisions.replacementPins.filter((item) => item.packageId !== packageId);
205
+ const changed = replacementPins.length !== decisions.replacementPins.length;
206
+ if (!changed)
207
+ return false;
208
+ decisions.replacementPins = replacementPins;
209
+ await ensureDirectory(dirname(decisionsPath()));
210
+ await writeFileAtomically(decisionsPath(), `${JSON.stringify(decisions, null, 2)}\n`);
211
+ return true;
212
+ }
213
+ export async function readReplacementPins() {
214
+ return (await readDecisions()).replacementPins;
215
+ }
216
+ export function formatFreshnessAlerts(alerts) {
217
+ const visible = alerts.filter((alert) => !alert.ignored);
218
+ if (!visible.length)
219
+ return alerts.length
220
+ ? "No unignored freshness alerts."
221
+ : "No freshness or replacement alert is supported by current evidence.";
222
+ return [
223
+ `Freshness alerts: ${visible.length}`,
224
+ ...visible.flatMap((alert) => [
225
+ `${alert.severity === "critical" ? "✗" : alert.severity === "warning" ? "!" : "•"} ${alert.id}: ${alert.packageId} — ${alert.message}`,
226
+ ` Evidence: ${alert.evidence.join("; ")}`,
227
+ ` Actions: ${alert.actions.join(" | ")}`,
228
+ ]),
229
+ ].join("\n");
230
+ }
@@ -0,0 +1,97 @@
1
+ /** A rolling window prevents the default discovery query from aging in place. */
2
+ export function defaultGitHubDiscoveryQuery(now = new Date()) {
3
+ return defaultGitHubDiscoveryQueries(now)[0];
4
+ }
5
+ /**
6
+ * GitHub repository search does not interpret parenthesized topic OR clauses
7
+ * as three independent topic searches. Keep the built-in sweep broad with a
8
+ * small number of valid queries, then merge them deterministically.
9
+ */
10
+ export function defaultGitHubDiscoveryQueries(now = new Date()) {
11
+ const start = new Date(now.getTime() - 180 * 86_400_000)
12
+ .toISOString()
13
+ .slice(0, 10);
14
+ return ["mcp", "ai-agent", "agent-skills"].map((topic) => `topic:${topic} created:>=${start}`);
15
+ }
16
+ /** Search public GitHub repositories through the documented REST API. */
17
+ export async function discoverGitHubRepositories(options) {
18
+ if (options.query !== undefined && options.queries !== undefined)
19
+ throw new Error("GitHub discovery accepts either query or queries, not both");
20
+ const queries = (options.queries ??
21
+ (options.query === undefined
22
+ ? defaultGitHubDiscoveryQueries()
23
+ : [options.query]))
24
+ .map((query) => query.trim())
25
+ .filter(Boolean);
26
+ if (!queries.length)
27
+ throw new Error("GitHub discovery requires a non-empty query");
28
+ const limit = Math.max(1, Math.min(options.limit ?? 20, 100));
29
+ const token = options.token ?? process.env.GITHUB_TOKEN;
30
+ const headers = {
31
+ accept: "application/vnd.github+json",
32
+ "user-agent": "loadout-discovery",
33
+ };
34
+ if (token)
35
+ headers.authorization = `Bearer ${token}`;
36
+ const leads = [];
37
+ for (const query of queries) {
38
+ const params = new URLSearchParams({
39
+ q: query,
40
+ sort: "stars",
41
+ order: "desc",
42
+ per_page: String(limit),
43
+ });
44
+ const response = await (options.fetcher ?? fetch)(`https://api.github.com/search/repositories?${params}`, { headers });
45
+ if (!response.ok) {
46
+ const remaining = response.headers.get("x-ratelimit-remaining");
47
+ const reset = response.headers.get("x-ratelimit-reset");
48
+ const retryAfter = response.headers.get("retry-after");
49
+ const resetAt = reset && /^\d+$/.test(reset)
50
+ ? new Date(Number(reset) * 1000).toISOString()
51
+ : undefined;
52
+ throw new Error(`GitHub repository discovery failed (${response.status})${remaining === "0" ? `; rate limit exhausted${resetAt ? ` until ${resetAt}` : ""}` : retryAfter ? `; retry after ${retryAfter} second(s)` : ""}`);
53
+ }
54
+ const value = await response.json();
55
+ if (!value || typeof value !== "object")
56
+ throw new Error("GitHub repository discovery response is invalid");
57
+ const items = value.items;
58
+ if (!Array.isArray(items))
59
+ throw new Error("GitHub discovery items are invalid");
60
+ leads.push(...items.flatMap((item) => {
61
+ if (!item || typeof item !== "object")
62
+ return [];
63
+ const record = item;
64
+ if (typeof record.full_name !== "string" ||
65
+ typeof record.html_url !== "string")
66
+ return [];
67
+ return [
68
+ {
69
+ source: "github-search",
70
+ repository: record.full_name,
71
+ title: record.full_name,
72
+ description: typeof record.description === "string" ? record.description : "",
73
+ url: record.html_url,
74
+ stars: typeof record.stargazers_count === "number"
75
+ ? record.stargazers_count
76
+ : 0,
77
+ forks: typeof record.forks_count === "number" ? record.forks_count : 0,
78
+ createdAt: typeof record.created_at === "string" ? record.created_at : "",
79
+ updatedAt: typeof record.updated_at === "string" ? record.updated_at : "",
80
+ query,
81
+ },
82
+ ];
83
+ }));
84
+ }
85
+ const unique = new Map();
86
+ for (const lead of leads) {
87
+ const key = lead.repository.toLowerCase();
88
+ const existing = unique.get(key);
89
+ if (!existing || lead.stars > existing.stars)
90
+ unique.set(key, lead);
91
+ }
92
+ return [...unique.values()]
93
+ .sort((a, b) => b.stars - a.stars ||
94
+ b.forks - a.forks ||
95
+ a.repository.localeCompare(b.repository))
96
+ .slice(0, limit);
97
+ }
@@ -0,0 +1,148 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadoutHome } from "./paths.js";
4
+ function normalizeRepository(input) {
5
+ const value = input
6
+ .trim()
7
+ .replace(/^https?:\/\/github\.com\//i, "")
8
+ .replace(/\.git$/, "")
9
+ .replace(/\/$/, "");
10
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value))
11
+ throw new Error(`Invalid GitHub repository: ${input}`);
12
+ return value;
13
+ }
14
+ function cachePath(repository) {
15
+ return join(loadoutHome(), "cache", "github-metadata", `${repository.replace("/", "__")}.json`);
16
+ }
17
+ function releaseCachePath(repository) {
18
+ return join(loadoutHome(), "cache", "github-releases", `${repository.replace("/", "__")}.json`);
19
+ }
20
+ async function readCache(path) {
21
+ try {
22
+ return JSON.parse(await readFile(path, "utf8"));
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ /** Fetches real repository metadata from GitHub, with a local cache and stale fallback. */
29
+ export async function fetchGitHubMetadata(input, options = {}) {
30
+ const repository = normalizeRepository(input);
31
+ const path = cachePath(repository);
32
+ const cached = await readCache(path);
33
+ const maxAgeMs = options.maxAgeMs ?? 6 * 60 * 60 * 1000;
34
+ if (cached &&
35
+ !options.forceRefresh &&
36
+ Date.now() - Date.parse(cached.fetchedAt) < maxAgeMs)
37
+ return cached;
38
+ const fetcher = options.fetcher ?? fetch;
39
+ const headers = {
40
+ accept: "application/vnd.github+json",
41
+ "user-agent": "loadout-discovery",
42
+ };
43
+ if (process.env.GITHUB_TOKEN)
44
+ headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
45
+ let response;
46
+ try {
47
+ response = await fetcher(`https://api.github.com/repos/${repository}`, {
48
+ headers,
49
+ });
50
+ }
51
+ catch (error) {
52
+ if (cached)
53
+ return cached;
54
+ throw new Error(`Unable to reach GitHub for ${repository}: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ if (!response.ok) {
57
+ const rateLimited = response.status === 403 || response.status === 429;
58
+ if (cached)
59
+ return cached;
60
+ throw new Error(`GitHub metadata request failed (${response.status}${rateLimited ? ", rate limit exceeded" : ""}) for ${repository}`);
61
+ }
62
+ const value = (await response.json());
63
+ const metadata = {
64
+ repository,
65
+ stars: typeof value.stargazers_count === "number" ? value.stargazers_count : 0,
66
+ description: typeof value.description === "string" ? value.description : "",
67
+ defaultBranch: typeof value.default_branch === "string" ? value.default_branch : "main",
68
+ topics: Array.isArray(value.topics)
69
+ ? value.topics.filter((topic) => typeof topic === "string")
70
+ : [],
71
+ openIssues: typeof value.open_issues_count === "number" ? value.open_issues_count : 0,
72
+ archived: value.archived === true,
73
+ lastUpdatedAt: typeof value.updated_at === "string"
74
+ ? value.updated_at
75
+ : new Date().toISOString(),
76
+ pushedAt: typeof value.pushed_at === "string" ? value.pushed_at : null,
77
+ fetchedAt: new Date().toISOString(),
78
+ };
79
+ await mkdir(join(loadoutHome(), "cache", "github-metadata"), {
80
+ recursive: true,
81
+ });
82
+ const temporary = `${path}.${process.pid}.tmp`;
83
+ await writeFile(temporary, JSON.stringify(metadata, null, 2), "utf8");
84
+ await rename(temporary, path);
85
+ return metadata;
86
+ }
87
+ /**
88
+ * Read the latest public GitHub release and aggregate its asset downloads.
89
+ * Repositories without releases return explicit null/zero values. Drafts are
90
+ * never exposed by GitHub's public release listing.
91
+ */
92
+ export async function fetchGitHubReleaseMetadata(input, options = {}) {
93
+ const repository = normalizeRepository(input);
94
+ const path = releaseCachePath(repository);
95
+ const cached = await readCache(path);
96
+ const maxAgeMs = options.maxAgeMs ?? 6 * 60 * 60 * 1000;
97
+ if (cached &&
98
+ !options.forceRefresh &&
99
+ Date.now() - Date.parse(cached.fetchedAt) < maxAgeMs)
100
+ return cached;
101
+ const fetcher = options.fetcher ?? fetch;
102
+ const headers = {
103
+ accept: "application/vnd.github+json",
104
+ "user-agent": "loadout-discovery",
105
+ };
106
+ if (process.env.GITHUB_TOKEN)
107
+ headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
108
+ let response;
109
+ try {
110
+ response = await fetcher(`https://api.github.com/repos/${repository}/releases?per_page=1`, { headers });
111
+ }
112
+ catch (error) {
113
+ if (cached)
114
+ return cached;
115
+ throw new Error(`Unable to reach GitHub releases for ${repository}: ${error instanceof Error ? error.message : String(error)}`);
116
+ }
117
+ if (!response.ok) {
118
+ if (cached)
119
+ return cached;
120
+ throw new Error(`GitHub release request failed (${response.status}) for ${repository}`);
121
+ }
122
+ const releases = (await response.json());
123
+ const release = Array.isArray(releases) ? releases[0] : undefined;
124
+ const record = release && typeof release === "object" ? release : {};
125
+ const assets = Array.isArray(record.assets)
126
+ ? record.assets
127
+ : [];
128
+ const metadata = {
129
+ tag: typeof record.tag_name === "string"
130
+ ? record.tag_name
131
+ : null,
132
+ publishedAt: typeof record.published_at === "string"
133
+ ? record.published_at
134
+ : null,
135
+ downloadCount: assets.reduce((total, asset) => total +
136
+ (typeof asset.download_count === "number" && asset.download_count >= 0
137
+ ? asset.download_count
138
+ : 0), 0),
139
+ fetchedAt: new Date().toISOString(),
140
+ };
141
+ await mkdir(join(loadoutHome(), "cache", "github-releases"), {
142
+ recursive: true,
143
+ });
144
+ const temporary = `${path}.${process.pid}.tmp`;
145
+ await writeFile(temporary, JSON.stringify(metadata, null, 2), "utf8");
146
+ await rename(temporary, path);
147
+ return metadata;
148
+ }