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.
- package/LICENSE +21 -0
- package/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
export const RELEASE_CLAIMS = [
|
|
6
|
+
{
|
|
7
|
+
id: "catalog-immutable-attribution",
|
|
8
|
+
status: "supported",
|
|
9
|
+
statement: "The bundled catalog records immutable Git commits and upstream credit links.",
|
|
10
|
+
boundary: "A pin proves source identity; it does not prove safety, license permission, usefulness, or future compatibility.",
|
|
11
|
+
evidence: {
|
|
12
|
+
files: [
|
|
13
|
+
"catalog/packages.json",
|
|
14
|
+
"docs/CATALOG.md",
|
|
15
|
+
"scripts/check-catalog-attribution.mjs",
|
|
16
|
+
],
|
|
17
|
+
commands: ["npm run check:evidence"],
|
|
18
|
+
sources: [],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "stable-bounded-profile",
|
|
23
|
+
status: "bounded",
|
|
24
|
+
statement: "Stable selects 30 skill directories from four pinned SPDX-identified sources.",
|
|
25
|
+
boundary: "Recommended means the bounded Loadout policy default, not universal task superiority.",
|
|
26
|
+
evidence: {
|
|
27
|
+
files: [
|
|
28
|
+
"src/core/profiles.ts",
|
|
29
|
+
"tests/profiles.test.ts",
|
|
30
|
+
"tests/catalog-install.test.ts",
|
|
31
|
+
],
|
|
32
|
+
commands: ["npm test -- --run"],
|
|
33
|
+
sources: [],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "transactional-managed-mutations",
|
|
38
|
+
status: "bounded",
|
|
39
|
+
statement: "Managed installation batches use snapshot-backed filesystem transactions and rollback.",
|
|
40
|
+
boundary: "This is not database-grade power-loss durability and never authorizes mutation of unmanaged content.",
|
|
41
|
+
evidence: {
|
|
42
|
+
files: [
|
|
43
|
+
"src/core/transaction.ts",
|
|
44
|
+
"src/core/snapshot.ts",
|
|
45
|
+
"tests/transaction.test.ts",
|
|
46
|
+
"tests/snapshot.test.ts",
|
|
47
|
+
"tests/native-filesystem-smoke.test.ts",
|
|
48
|
+
],
|
|
49
|
+
commands: ["npm run test:e2e:cli"],
|
|
50
|
+
sources: [],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "daily-read-only-autopilot",
|
|
55
|
+
status: "bounded",
|
|
56
|
+
statement: "Autopilot schedules read-only discovery and update checks on supported native schedulers.",
|
|
57
|
+
boundary: "Scheduled jobs do not install, promote, execute, or update candidate content.",
|
|
58
|
+
evidence: {
|
|
59
|
+
files: ["src/core/scheduler.ts", "tests/scheduler.test.ts"],
|
|
60
|
+
commands: ["npm test -- --run tests/scheduler.test.ts"],
|
|
61
|
+
sources: [],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "benchmark-performance-evidence",
|
|
66
|
+
status: "not-established",
|
|
67
|
+
statement: "Loadout has a deterministic evaluation protocol and model-free campaign planner.",
|
|
68
|
+
boundary: "No bundled source is benchmarked until isolated real trials, signed evidence, and human promotion approval exist.",
|
|
69
|
+
evidence: {
|
|
70
|
+
files: [
|
|
71
|
+
"docs/EVALUATION_PROTOCOL_V1.md",
|
|
72
|
+
"src/core/benchmark-campaign.ts",
|
|
73
|
+
"tests/benchmark-campaign.test.ts",
|
|
74
|
+
"tests/benchmark-cli.test.ts",
|
|
75
|
+
],
|
|
76
|
+
commands: [
|
|
77
|
+
"npm test -- --run tests/benchmark-campaign.test.ts tests/benchmark-cli.test.ts",
|
|
78
|
+
],
|
|
79
|
+
sources: [],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "privacy-safe-sharing",
|
|
84
|
+
status: "bounded",
|
|
85
|
+
statement: "Cards, reports, comparisons, and badges expose aggregate local evidence without project content or credentials.",
|
|
86
|
+
boundary: "Users must review generated artifacts before sharing; scores do not establish universal quality.",
|
|
87
|
+
evidence: {
|
|
88
|
+
files: [
|
|
89
|
+
"src/core/share-report.ts",
|
|
90
|
+
"src/core/loadout-card.ts",
|
|
91
|
+
"src/core/loadout-badge.ts",
|
|
92
|
+
"tests/share-report.test.ts",
|
|
93
|
+
"tests/loadout-card.test.ts",
|
|
94
|
+
"tests/loadout-badge.test.ts",
|
|
95
|
+
],
|
|
96
|
+
commands: ["npm test -- --run"],
|
|
97
|
+
sources: [],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
export function releaseEvidenceRoot() {
|
|
102
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
103
|
+
const candidates = [
|
|
104
|
+
join(moduleDirectory, "..", ".."),
|
|
105
|
+
join(moduleDirectory, "..", "..", ".."),
|
|
106
|
+
process.cwd(),
|
|
107
|
+
];
|
|
108
|
+
return (candidates.find((candidate) => existsSync(join(candidate, "README.md"))) ??
|
|
109
|
+
candidates[0]);
|
|
110
|
+
}
|
|
111
|
+
const FORBIDDEN_PUBLIC_CLAIMS = [
|
|
112
|
+
/\buniversally best\b/i,
|
|
113
|
+
/\bguaranteed safe\b/i,
|
|
114
|
+
/\bzero[- ]risk\b/i,
|
|
115
|
+
/\bperfect(?:ly)? compatible\b/i,
|
|
116
|
+
/\bproves? task improvement\b/i,
|
|
117
|
+
/\bevery (?:skill|repository) is (?:safe|reviewed|benchmarked)\b/i,
|
|
118
|
+
];
|
|
119
|
+
function portableEvidencePath(path) {
|
|
120
|
+
return (path.length > 0 &&
|
|
121
|
+
!path.startsWith("/") &&
|
|
122
|
+
!path.includes("\\") &&
|
|
123
|
+
!path.split("/").includes(".."));
|
|
124
|
+
}
|
|
125
|
+
export async function auditReleaseClaims(options) {
|
|
126
|
+
const claims = [...(options.claims ?? RELEASE_CLAIMS)];
|
|
127
|
+
const blockers = [];
|
|
128
|
+
const ids = new Set();
|
|
129
|
+
for (const claim of claims) {
|
|
130
|
+
if (ids.has(claim.id))
|
|
131
|
+
blockers.push(`duplicate claim id: ${claim.id}`);
|
|
132
|
+
ids.add(claim.id);
|
|
133
|
+
if (!claim.statement.trim() || !claim.boundary.trim())
|
|
134
|
+
blockers.push(`claim ${claim.id} lacks statement or boundary`);
|
|
135
|
+
if (!claim.evidence.files.length)
|
|
136
|
+
blockers.push(`claim ${claim.id} has no evidence files`);
|
|
137
|
+
for (const path of claim.evidence.files) {
|
|
138
|
+
if (!portableEvidencePath(path)) {
|
|
139
|
+
blockers.push(`claim ${claim.id} has unsafe evidence path: ${path}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (options.verifyEvidenceFiles !== false)
|
|
143
|
+
try {
|
|
144
|
+
await access(resolve(options.root, path));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
blockers.push(`claim ${claim.id} evidence is missing: ${path}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const pattern of FORBIDDEN_PUBLIC_CLAIMS)
|
|
152
|
+
if (pattern.test(options.readme))
|
|
153
|
+
blockers.push(`README contains forbidden release claim: ${pattern}`);
|
|
154
|
+
const catalogPhrase = `${options.catalogCount} credited public repositories`;
|
|
155
|
+
if (!options.readme.includes(catalogPhrase))
|
|
156
|
+
blockers.push(`README catalog count is stale or missing; expected '${catalogPhrase}'`);
|
|
157
|
+
if (!/does not claim there is one universally [“"]best[”"] configuration/i.test(options.readme))
|
|
158
|
+
blockers.push("README lacks the universal-best claim boundary");
|
|
159
|
+
if (!/no bundled source is called benchmarked/i.test(options.readme))
|
|
160
|
+
blockers.push("README lacks the no-bundled-benchmark-evidence boundary");
|
|
161
|
+
return {
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
policyVersion: "p16-17-v1",
|
|
164
|
+
claims,
|
|
165
|
+
releaseBlocked: blockers.length > 0,
|
|
166
|
+
blockers,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function formatReleaseEvidenceIndex(index) {
|
|
170
|
+
return [
|
|
171
|
+
`Release claim gate: ${index.releaseBlocked ? "BLOCKED" : "PASS"} (${index.claims.length} claim(s))`,
|
|
172
|
+
...index.claims.map((claim) => `${claim.status === "supported" ? "✓" : claim.status === "bounded" ? "△" : "○"} ${claim.id} — ${claim.statement}\n Boundary: ${claim.boundary}`),
|
|
173
|
+
...index.blockers.map((blocker) => `BLOCKER: ${blocker}`),
|
|
174
|
+
].join("\n");
|
|
175
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { forgetInstall, installStatePath, readInstallState } from "./state.js";
|
|
4
|
+
import { writeMcpConfigPlan } from "./mcp.js";
|
|
5
|
+
import { runMutationTransaction } from "./transaction.js";
|
|
6
|
+
export async function planRemove(packageId) {
|
|
7
|
+
const record = (await readInstallState()).installs.find((entry) => entry.packageId === packageId);
|
|
8
|
+
if (!record)
|
|
9
|
+
throw new Error(`Package is not managed by Loadout: ${packageId}`);
|
|
10
|
+
const files = await Promise.all(record.files.map(async (file) => {
|
|
11
|
+
try {
|
|
12
|
+
const digest = createHash("sha256")
|
|
13
|
+
.update(await readFile(file.path))
|
|
14
|
+
.digest("hex");
|
|
15
|
+
return {
|
|
16
|
+
path: file.path,
|
|
17
|
+
status: digest === file.sha256
|
|
18
|
+
? "unchanged"
|
|
19
|
+
: "modified",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { path: file.path, status: "missing" };
|
|
24
|
+
}
|
|
25
|
+
}));
|
|
26
|
+
const modified = files.filter((file) => file.status === "modified");
|
|
27
|
+
const state = await readInstallState();
|
|
28
|
+
const mcpServers = await Promise.all((state.mcpInstalls ?? [])
|
|
29
|
+
.filter((entry) => entry.packageId === packageId)
|
|
30
|
+
.map(async (entry) => {
|
|
31
|
+
try {
|
|
32
|
+
const config = JSON.parse(await readFile(entry.configPath, "utf8"));
|
|
33
|
+
if (!config.mcpServers || !(entry.serverName in config.mcpServers))
|
|
34
|
+
return {
|
|
35
|
+
configPath: entry.configPath,
|
|
36
|
+
serverName: entry.serverName,
|
|
37
|
+
status: "missing",
|
|
38
|
+
};
|
|
39
|
+
const fingerprint = createHash("sha256")
|
|
40
|
+
.update(JSON.stringify(config.mcpServers[entry.serverName]))
|
|
41
|
+
.digest("hex");
|
|
42
|
+
return {
|
|
43
|
+
configPath: entry.configPath,
|
|
44
|
+
serverName: entry.serverName,
|
|
45
|
+
status: fingerprint === entry.fingerprint
|
|
46
|
+
? "unchanged"
|
|
47
|
+
: "modified",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {
|
|
52
|
+
configPath: entry.configPath,
|
|
53
|
+
serverName: entry.serverName,
|
|
54
|
+
status: "missing",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
const modifiedMcp = mcpServers.filter((entry) => entry.status === "modified");
|
|
59
|
+
const warnings = [
|
|
60
|
+
...(modified.length
|
|
61
|
+
? [`${modified.length} managed file(s) were modified outside Loadout.`]
|
|
62
|
+
: []),
|
|
63
|
+
...(modifiedMcp.length
|
|
64
|
+
? [
|
|
65
|
+
`${modifiedMcp.length} managed MCP server entry or entries were modified outside Loadout.`,
|
|
66
|
+
]
|
|
67
|
+
: []),
|
|
68
|
+
];
|
|
69
|
+
if (warnings.length)
|
|
70
|
+
warnings.push("Removal is blocked unless --force is used.");
|
|
71
|
+
return {
|
|
72
|
+
packageId,
|
|
73
|
+
files,
|
|
74
|
+
mcpServers,
|
|
75
|
+
blocked: modified.length > 0 || modifiedMcp.length > 0,
|
|
76
|
+
warnings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function applyRemove(plan, options = {}) {
|
|
80
|
+
const applied = await runMutationTransaction(async () => {
|
|
81
|
+
const fresh = await planRemove(plan.packageId);
|
|
82
|
+
if (fresh.blocked && !options.force)
|
|
83
|
+
throw new Error(fresh.warnings.join(" "));
|
|
84
|
+
const existing = fresh.files
|
|
85
|
+
.filter((file) => file.status !== "missing")
|
|
86
|
+
.map((file) => file.path);
|
|
87
|
+
const configPaths = [
|
|
88
|
+
...new Set(fresh.mcpServers
|
|
89
|
+
.filter((entry) => entry.status !== "missing")
|
|
90
|
+
.map((entry) => entry.configPath)),
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
targets: [...existing, ...configPaths, installStatePath()],
|
|
94
|
+
value: { fresh, existing, configPaths },
|
|
95
|
+
};
|
|
96
|
+
}, async ({ fresh, existing, configPaths }) => {
|
|
97
|
+
for (const file of existing)
|
|
98
|
+
await rm(file, { force: true });
|
|
99
|
+
for (const configPath of configPaths) {
|
|
100
|
+
const current = JSON.parse(await readFile(configPath, "utf8"));
|
|
101
|
+
const servers = {
|
|
102
|
+
...(current.mcpServers ?? {}),
|
|
103
|
+
};
|
|
104
|
+
const removals = fresh.mcpServers.filter((entry) => entry.configPath === configPath && entry.status !== "missing");
|
|
105
|
+
for (const entry of removals)
|
|
106
|
+
delete servers[entry.serverName];
|
|
107
|
+
await writeMcpConfigPlan({
|
|
108
|
+
path: configPath,
|
|
109
|
+
serverName: removals[0]?.serverName ?? "removed",
|
|
110
|
+
changes: removals.map((entry) => ({
|
|
111
|
+
serverName: entry.serverName,
|
|
112
|
+
action: "replace",
|
|
113
|
+
summary: `Remove MCP server '${entry.serverName}'`,
|
|
114
|
+
})),
|
|
115
|
+
warnings: [],
|
|
116
|
+
proposed: { ...current, mcpServers: servers },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
await forgetInstall(plan.packageId);
|
|
120
|
+
});
|
|
121
|
+
return applied.snapshotId;
|
|
122
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
4
|
+
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
|
+
const queuePath = () => join(loadoutHome(), "review-queue.json");
|
|
6
|
+
function isQueue(value) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
8
|
+
return false;
|
|
9
|
+
const record = value;
|
|
10
|
+
return (record.schemaVersion === 1 &&
|
|
11
|
+
typeof record.updatedAt === "string" &&
|
|
12
|
+
Array.isArray(record.items) &&
|
|
13
|
+
record.items.every((item) => item &&
|
|
14
|
+
typeof item === "object" &&
|
|
15
|
+
typeof item.repository === "string" &&
|
|
16
|
+
["pending", "shortlisted", "ignored"].includes(item.decision)));
|
|
17
|
+
}
|
|
18
|
+
export async function readReviewQueue() {
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(await readFile(queuePath(), "utf8"));
|
|
21
|
+
if (!isQueue(value))
|
|
22
|
+
throw new Error("review queue schema is invalid");
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error &&
|
|
27
|
+
typeof error === "object" &&
|
|
28
|
+
"code" in error &&
|
|
29
|
+
error.code === "ENOENT")
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
updatedAt: new Date(0).toISOString(),
|
|
33
|
+
items: [],
|
|
34
|
+
};
|
|
35
|
+
throw new Error(`Loadout review queue is invalid at ${queuePath()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function fromLead(lead, now, cataloged) {
|
|
39
|
+
if (lead.source === "github-search")
|
|
40
|
+
return {
|
|
41
|
+
repository: lead.repository,
|
|
42
|
+
url: lead.url,
|
|
43
|
+
title: lead.title,
|
|
44
|
+
description: lead.description,
|
|
45
|
+
sources: [lead.source],
|
|
46
|
+
firstSeenAt: now,
|
|
47
|
+
lastSeenAt: now,
|
|
48
|
+
decision: "pending",
|
|
49
|
+
alreadyCataloged: cataloged.has(lead.repository.toLowerCase()),
|
|
50
|
+
stars: lead.stars,
|
|
51
|
+
starVelocityBaselineStars: lead.stars,
|
|
52
|
+
starVelocityBaselineAt: now,
|
|
53
|
+
forks: lead.forks,
|
|
54
|
+
repositoryCreatedAt: lead.createdAt,
|
|
55
|
+
repositoryUpdatedAt: lead.updatedAt,
|
|
56
|
+
};
|
|
57
|
+
if (lead.source === "hacker-news")
|
|
58
|
+
return {
|
|
59
|
+
repository: lead.repository,
|
|
60
|
+
url: lead.storyUrl,
|
|
61
|
+
title: lead.title,
|
|
62
|
+
description: "",
|
|
63
|
+
sources: [lead.source],
|
|
64
|
+
firstSeenAt: now,
|
|
65
|
+
lastSeenAt: now,
|
|
66
|
+
decision: "pending",
|
|
67
|
+
alreadyCataloged: cataloged.has(lead.repository.toLowerCase()),
|
|
68
|
+
communityScore: lead.score,
|
|
69
|
+
discussionUrl: lead.discussionUrl,
|
|
70
|
+
};
|
|
71
|
+
if (lead.source === "skills-sh" && lead.repository)
|
|
72
|
+
return {
|
|
73
|
+
repository: lead.repository.repository,
|
|
74
|
+
url: lead.repository.url,
|
|
75
|
+
title: lead.name,
|
|
76
|
+
description: lead.ranking.meaning,
|
|
77
|
+
sources: [lead.source],
|
|
78
|
+
firstSeenAt: now,
|
|
79
|
+
lastSeenAt: now,
|
|
80
|
+
decision: "pending",
|
|
81
|
+
alreadyCataloged: cataloged.has(lead.repository.repository.toLowerCase()),
|
|
82
|
+
installs: lead.installs,
|
|
83
|
+
};
|
|
84
|
+
if (lead.source === "official-mcp-registry" && lead.repository?.repository)
|
|
85
|
+
return {
|
|
86
|
+
repository: lead.repository.repository,
|
|
87
|
+
url: lead.repository.url,
|
|
88
|
+
title: lead.title ?? lead.name,
|
|
89
|
+
description: lead.description,
|
|
90
|
+
sources: [lead.source],
|
|
91
|
+
firstSeenAt: now,
|
|
92
|
+
lastSeenAt: now,
|
|
93
|
+
decision: "pending",
|
|
94
|
+
alreadyCataloged: cataloged.has(lead.repository.repository.toLowerCase()),
|
|
95
|
+
registryVersion: lead.version,
|
|
96
|
+
lifecycleStatus: lead.verification.lifecycleStatus,
|
|
97
|
+
};
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
function decisionPriority(decision) {
|
|
101
|
+
if (decision === "shortlisted")
|
|
102
|
+
return 0;
|
|
103
|
+
if (decision === "pending")
|
|
104
|
+
return 1;
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
function compareQueueItems(left, right) {
|
|
108
|
+
return (Number(left.alreadyCataloged) - Number(right.alreadyCataloged) ||
|
|
109
|
+
decisionPriority(left.decision) - decisionPriority(right.decision) ||
|
|
110
|
+
(right.starVelocity ?? Number.NEGATIVE_INFINITY) -
|
|
111
|
+
(left.starVelocity ?? Number.NEGATIVE_INFINITY) ||
|
|
112
|
+
(right.stars ?? right.communityScore ?? 0) -
|
|
113
|
+
(left.stars ?? left.communityScore ?? 0) ||
|
|
114
|
+
left.repository.localeCompare(right.repository));
|
|
115
|
+
}
|
|
116
|
+
/** Merge read-only discovery leads; this never promotes or installs a candidate. */
|
|
117
|
+
export async function mergeReviewQueue(leads, catalog, now = new Date()) {
|
|
118
|
+
const current = await readReviewQueue();
|
|
119
|
+
const timestamp = now.toISOString();
|
|
120
|
+
const cataloged = new Set(catalog.map((item) => item.repository.toLowerCase()));
|
|
121
|
+
const items = new Map(current.items.map((item) => [item.repository.toLowerCase(), item]));
|
|
122
|
+
for (const lead of leads) {
|
|
123
|
+
const incoming = fromLead(lead, timestamp, cataloged);
|
|
124
|
+
if (!incoming)
|
|
125
|
+
continue;
|
|
126
|
+
const key = incoming.repository.toLowerCase();
|
|
127
|
+
const existing = items.get(key);
|
|
128
|
+
let velocityEvidence = {};
|
|
129
|
+
if (existing &&
|
|
130
|
+
incoming.stars !== undefined &&
|
|
131
|
+
existing.stars !== undefined) {
|
|
132
|
+
const baselineStars = existing.starVelocityBaselineStars ?? existing.stars;
|
|
133
|
+
const baselineAt = existing.starVelocityBaselineAt ?? existing.lastSeenAt;
|
|
134
|
+
const elapsedDays = (now.getTime() - Date.parse(baselineAt)) / 86_400_000;
|
|
135
|
+
if (Number.isFinite(elapsedDays) && elapsedDays >= 1) {
|
|
136
|
+
velocityEvidence = {
|
|
137
|
+
starVelocity: (incoming.stars - baselineStars) / elapsedDays,
|
|
138
|
+
starVelocityWindowDays: elapsedDays,
|
|
139
|
+
starVelocityMeasuredAt: timestamp,
|
|
140
|
+
starVelocityBaselineStars: incoming.stars,
|
|
141
|
+
starVelocityBaselineAt: timestamp,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
else if (Number.isFinite(elapsedDays) && elapsedDays > 0) {
|
|
145
|
+
velocityEvidence = {
|
|
146
|
+
starVelocity: existing.starVelocity,
|
|
147
|
+
starVelocityWindowDays: existing.starVelocityWindowDays,
|
|
148
|
+
starVelocityMeasuredAt: existing.starVelocityMeasuredAt,
|
|
149
|
+
starVelocityBaselineStars: baselineStars,
|
|
150
|
+
starVelocityBaselineAt: baselineAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
velocityEvidence = {
|
|
155
|
+
starVelocity: existing.starVelocity,
|
|
156
|
+
starVelocityWindowDays: existing.starVelocityWindowDays,
|
|
157
|
+
starVelocityMeasuredAt: existing.starVelocityMeasuredAt,
|
|
158
|
+
starVelocityBaselineStars: incoming.stars,
|
|
159
|
+
starVelocityBaselineAt: timestamp,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
items.set(key, existing
|
|
164
|
+
? {
|
|
165
|
+
...existing,
|
|
166
|
+
...incoming,
|
|
167
|
+
sources: [...new Set([...existing.sources, ...incoming.sources])],
|
|
168
|
+
firstSeenAt: existing.firstSeenAt,
|
|
169
|
+
decision: existing.decision,
|
|
170
|
+
...velocityEvidence,
|
|
171
|
+
}
|
|
172
|
+
: incoming);
|
|
173
|
+
}
|
|
174
|
+
const queue = {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
updatedAt: timestamp,
|
|
177
|
+
items: [...items.values()].sort(compareQueueItems),
|
|
178
|
+
};
|
|
179
|
+
await ensureDirectory(dirname(queuePath()));
|
|
180
|
+
await writeFileAtomically(queuePath(), `${JSON.stringify(queue, null, 2)}\n`);
|
|
181
|
+
return queue;
|
|
182
|
+
}
|
|
183
|
+
export async function setReviewDecision(repository, decision) {
|
|
184
|
+
const queue = await readReviewQueue();
|
|
185
|
+
const item = queue.items.find((candidate) => candidate.repository.toLowerCase() === repository.toLowerCase());
|
|
186
|
+
if (!item)
|
|
187
|
+
throw new Error(`Repository is not in the review queue: ${repository}. Run loadout discover --queue first.`);
|
|
188
|
+
item.decision = decision;
|
|
189
|
+
queue.items.sort(compareQueueItems);
|
|
190
|
+
queue.updatedAt = new Date().toISOString();
|
|
191
|
+
await writeFileAtomically(queuePath(), `${JSON.stringify(queue, null, 2)}\n`);
|
|
192
|
+
return item;
|
|
193
|
+
}
|
|
194
|
+
export function formatReviewQueue(queue) {
|
|
195
|
+
const visible = queue.items.filter((item) => !item.alreadyCataloged);
|
|
196
|
+
return [
|
|
197
|
+
`Review queue: ${visible.length} uncataloged candidate(s), ${queue.items.length - visible.length} already cataloged`,
|
|
198
|
+
...visible.map((item) => `${item.decision === "shortlisted" ? "★" : item.decision === "ignored" ? "×" : "○"} ${item.repository} — ${item.stars !== undefined ? `${item.stars} stars${item.starVelocity !== undefined ? ` (${item.starVelocity >= 0 ? "+" : ""}${item.starVelocity.toFixed(1)}/day over ${item.starVelocityWindowDays?.toFixed(2) ?? "unknown"} days)` : ""}` : item.installs !== undefined ? `${item.installs} skills.sh installs` : item.registryVersion ? `MCP ${item.registryVersion} (${item.lifecycleStatus ?? "unknown"})` : `community score ${item.communityScore ?? 0}`} — ${item.sources.join("+")}`),
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|