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,287 @@
1
+ import { readFile, readdir, mkdir, rename, writeFile, rm, } from "node:fs/promises";
2
+ import { join, resolve, dirname, basename } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { loadoutHome } from "./paths.js";
5
+ import { runMutationTransaction, withMutationLock } from "./transaction.js";
6
+ const missingConfigMarker = "loadout:mcp-config:missing";
7
+ async function configFingerprint(path) {
8
+ let content;
9
+ try {
10
+ content = await readFile(path, "utf8");
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ content = missingConfigMarker;
15
+ else
16
+ throw error;
17
+ }
18
+ return createHash("sha256").update(content).digest("hex");
19
+ }
20
+ const MANIFEST_NAMES = new Set([
21
+ "mcp.json",
22
+ ".mcp.json",
23
+ "claude_desktop_config.json",
24
+ ]);
25
+ function asString(value) {
26
+ return typeof value === "string" && value.trim() ? value : undefined;
27
+ }
28
+ function normalizeServer(name, value, sourcePath) {
29
+ const warnings = [];
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
31
+ return {
32
+ name,
33
+ args: [],
34
+ env: {},
35
+ sourcePath,
36
+ warnings: ["server entry must be an object"],
37
+ };
38
+ }
39
+ const raw = value;
40
+ const command = asString(raw.command);
41
+ const url = asString(raw.url) ?? asString(raw.endpoint);
42
+ if (!command && !url)
43
+ warnings.push("server has neither a command nor a URL");
44
+ if (command && url)
45
+ warnings.push("server has both command and URL; command will be preferred by clients");
46
+ const args = Array.isArray(raw.args)
47
+ ? raw.args.filter((arg) => typeof arg === "string")
48
+ : [];
49
+ if (raw.args !== undefined &&
50
+ (!Array.isArray(raw.args) || args.length !== raw.args.length))
51
+ warnings.push("non-string args were ignored");
52
+ const env = {};
53
+ if (raw.env && typeof raw.env === "object" && !Array.isArray(raw.env)) {
54
+ for (const [key, entry] of Object.entries(raw.env)) {
55
+ if (typeof entry === "string")
56
+ env[key] = entry;
57
+ else
58
+ warnings.push(`environment value ${key} was ignored because it is not a string`);
59
+ }
60
+ }
61
+ else if (raw.env !== undefined)
62
+ warnings.push("env must be an object");
63
+ return { name, command, url, args, env, sourcePath, warnings };
64
+ }
65
+ export function parseMcpManifest(value, sourcePath) {
66
+ const warnings = [];
67
+ const root = value && typeof value === "object" && !Array.isArray(value)
68
+ ? value
69
+ : {};
70
+ if (root !== value)
71
+ warnings.push("manifest root must be an object");
72
+ const rawServers = root.mcpServers ?? root.servers;
73
+ if (!rawServers ||
74
+ typeof rawServers !== "object" ||
75
+ Array.isArray(rawServers)) {
76
+ warnings.push("manifest has no mcpServers object");
77
+ return { path: sourcePath, servers: [], warnings };
78
+ }
79
+ const servers = Object.entries(rawServers).map(([name, server]) => normalizeServer(name, server, sourcePath));
80
+ return { path: sourcePath, servers, warnings };
81
+ }
82
+ async function findManifestFiles(root) {
83
+ const found = [];
84
+ async function visit(directory, depth) {
85
+ if (depth > 5)
86
+ return;
87
+ let entries;
88
+ try {
89
+ entries = await readdir(directory, { withFileTypes: true });
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ for (const entry of entries) {
95
+ if (entry.name === ".git" || entry.name === "node_modules")
96
+ continue;
97
+ const path = join(directory, entry.name);
98
+ if (entry.isDirectory())
99
+ await visit(path, depth + 1);
100
+ else if (entry.isFile() && MANIFEST_NAMES.has(entry.name))
101
+ found.push(path);
102
+ }
103
+ }
104
+ await visit(resolve(root), 0);
105
+ return found;
106
+ }
107
+ export async function discoverMcpManifests(root) {
108
+ const manifests = [];
109
+ for (const path of await findManifestFiles(root)) {
110
+ try {
111
+ manifests.push(parseMcpManifest(JSON.parse(await readFile(path, "utf8")), path));
112
+ }
113
+ catch (error) {
114
+ manifests.push({
115
+ path,
116
+ servers: [],
117
+ warnings: [
118
+ `invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
119
+ ],
120
+ });
121
+ }
122
+ }
123
+ return manifests;
124
+ }
125
+ /** Safe for terminal/UI output: never emits environment values or secrets. */
126
+ export function summarizeMcpManifest(manifest) {
127
+ const lines = [`${manifest.path}: ${manifest.servers.length} MCP server(s)`];
128
+ for (const server of manifest.servers) {
129
+ const transport = server.command
130
+ ? `command ${server.command}`
131
+ : server.url
132
+ ? `URL ${server.url}`
133
+ : "invalid transport";
134
+ lines.push(` - ${server.name} (${transport}${server.env ? `, ${Object.keys(server.env).length} env var(s)` : ""})`);
135
+ for (const warning of server.warnings)
136
+ lines.push(` warning: ${warning}`);
137
+ }
138
+ for (const warning of manifest.warnings)
139
+ lines.push(` warning: ${warning}`);
140
+ return lines.join("\n");
141
+ }
142
+ function safeName(name) {
143
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(name))
144
+ throw new Error("MCP server name may contain only letters, numbers, ., _, and -");
145
+ }
146
+ /** Build a mutation without touching disk. Existing top-level config keys are preserved. */
147
+ export async function planMcpConfig(path, server, name = server.name) {
148
+ return planMcpConfigBatch(path, [{ server, name }]);
149
+ }
150
+ export async function planMcpConfigBatch(path, entries) {
151
+ if (!entries.length)
152
+ throw new Error("MCP configuration batch is empty");
153
+ for (const entry of entries)
154
+ safeName(entry.name ?? entry.server.name);
155
+ const target = resolve(path);
156
+ let current = {};
157
+ let baseSha256;
158
+ try {
159
+ const content = await readFile(target, "utf8");
160
+ baseSha256 = createHash("sha256").update(content).digest("hex");
161
+ const parsed = JSON.parse(content);
162
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
163
+ throw new Error("config root must be an object");
164
+ current = parsed;
165
+ }
166
+ catch (error) {
167
+ if (error.code === "ENOENT")
168
+ baseSha256 = createHash("sha256")
169
+ .update(missingConfigMarker)
170
+ .digest("hex");
171
+ else
172
+ throw new Error(`Cannot read MCP config ${target}: ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ const existing = current.mcpServers;
175
+ if (existing !== undefined &&
176
+ (!existing || typeof existing !== "object" || Array.isArray(existing)))
177
+ throw new Error("mcpServers must be an object");
178
+ const servers = { ...(existing ?? {}) };
179
+ const changes = [];
180
+ const warnings = [];
181
+ for (const item of entries) {
182
+ const server = item.server;
183
+ const name = item.name ?? server.name;
184
+ const had = Object.prototype.hasOwnProperty.call(servers, name);
185
+ const entry = server.url
186
+ ? {
187
+ url: server.url,
188
+ ...(server.args.length ? { args: server.args } : {}),
189
+ ...(Object.keys(server.env).length ? { env: server.env } : {}),
190
+ }
191
+ : {
192
+ command: server.command,
193
+ ...(server.args.length ? { args: server.args } : {}),
194
+ ...(Object.keys(server.env).length ? { env: server.env } : {}),
195
+ };
196
+ servers[name] = entry;
197
+ changes.push({
198
+ serverName: name,
199
+ action: had ? "replace" : "add",
200
+ summary: `${had ? "Replace" : "Add"} MCP server '${name}' (${server.url ? "URL" : "command"}; ${Object.keys(server.env).length} environment variable(s))`,
201
+ });
202
+ warnings.push(...server.warnings);
203
+ }
204
+ const proposed = { ...current, mcpServers: servers };
205
+ return {
206
+ path: target,
207
+ baseSha256,
208
+ serverName: changes[0].serverName,
209
+ changes,
210
+ warnings: [...new Set(warnings)],
211
+ proposed,
212
+ };
213
+ }
214
+ export function summarizeMcpConfigPlan(plan) {
215
+ return [
216
+ `MCP config: ${plan.path}`,
217
+ ...plan.changes.map((change) => ` - ${change.summary}`),
218
+ ...plan.warnings.map((warning) => ` warning: ${warning}`),
219
+ ].join("\n");
220
+ }
221
+ async function snapshotPath(id) {
222
+ return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
223
+ }
224
+ export async function applyMcpConfigPlan(plan) {
225
+ const applied = await runMutationTransaction(async () => {
226
+ await mkdir(dirname(plan.path), { recursive: true });
227
+ let existed = true;
228
+ let content;
229
+ try {
230
+ content = await readFile(plan.path, "utf8");
231
+ if (content.length > 10_000_000)
232
+ throw new Error("MCP config is unexpectedly large");
233
+ }
234
+ catch (error) {
235
+ if (error.code === "ENOENT")
236
+ existed = false;
237
+ else
238
+ throw error;
239
+ }
240
+ const snapshot = {
241
+ id: randomUUID(),
242
+ path: plan.path,
243
+ existed,
244
+ content,
245
+ createdAt: new Date().toISOString(),
246
+ };
247
+ const stored = await snapshotPath(snapshot.id);
248
+ return {
249
+ targets: [plan.path, stored],
250
+ value: { plan, snapshot, stored },
251
+ };
252
+ }, async ({ plan: freshPlan, snapshot, stored }) => {
253
+ await writeMcpConfigPlan(freshPlan);
254
+ await mkdir(dirname(stored), { recursive: true });
255
+ await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
256
+ return snapshot;
257
+ });
258
+ return applied.result;
259
+ }
260
+ /** Write an already-reviewed plan atomically; transaction callers own snapshot/rollback. */
261
+ export async function writeMcpConfigPlan(plan) {
262
+ if (plan.baseSha256 &&
263
+ (await configFingerprint(plan.path)) !== plan.baseSha256)
264
+ throw new Error(`MCP configuration changed after preview; prepare the plan again: ${plan.path}`);
265
+ await mkdir(dirname(plan.path), { recursive: true });
266
+ const temporary = join(dirname(plan.path), `.${basename(plan.path)}.loadout-${randomUUID()}.tmp`);
267
+ try {
268
+ await writeFile(temporary, `${JSON.stringify(plan.proposed, null, 2)}\n`, {
269
+ mode: 0o600,
270
+ });
271
+ await rename(temporary, plan.path);
272
+ }
273
+ catch (error) {
274
+ await rm(temporary, { force: true });
275
+ throw error;
276
+ }
277
+ }
278
+ export async function restoreMcpConfig(snapshot) {
279
+ await withMutationLock(async () => {
280
+ if (snapshot.existed) {
281
+ await mkdir(dirname(snapshot.path), { recursive: true });
282
+ await writeFile(snapshot.path, snapshot.content ?? "", { mode: 0o600 });
283
+ }
284
+ else
285
+ await rm(snapshot.path, { force: true });
286
+ });
287
+ }
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { formatSchemaError, providerModelConfigurationSchema, } from "../shared/schemas.js";
5
+ import { writeFileAtomically } from "./atomic-file.js";
6
+ import { ensureDirectory, loadoutHome } from "./paths.js";
7
+ import { runMutationTransaction } from "./transaction.js";
8
+ export const defaultModelConfigurationPath = () => join(loadoutHome(), "models.json");
9
+ /**
10
+ * Validate a portable, provider-neutral model configuration. This is not a
11
+ * provider adapter: it never reads environment variables, queries a keychain,
12
+ * sends a request, or writes an agent configuration file.
13
+ */
14
+ export function parseProviderModelConfiguration(value) {
15
+ try {
16
+ return providerModelConfigurationSchema.parse(value);
17
+ }
18
+ catch (error) {
19
+ if (error instanceof z.ZodError) {
20
+ throw new Error(`Model configuration is invalid: ${formatSchemaError(error)}`);
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+ export async function readProviderModelConfiguration(path = defaultModelConfigurationPath()) {
26
+ try {
27
+ return parseProviderModelConfiguration(JSON.parse(await readFile(path, "utf8")));
28
+ }
29
+ catch (error) {
30
+ if (error &&
31
+ typeof error === "object" &&
32
+ "code" in error &&
33
+ error.code === "ENOENT")
34
+ return undefined;
35
+ throw error;
36
+ }
37
+ }
38
+ export async function planProviderModelSelection(selection, path = defaultModelConfigurationPath()) {
39
+ const current = await readProviderModelConfiguration(path);
40
+ const replacing = Boolean(current?.selections.some((item) => item.id === selection.id));
41
+ const configuration = parseProviderModelConfiguration({
42
+ schemaVersion: 1,
43
+ selections: [
44
+ ...(current?.selections.filter((item) => item.id !== selection.id) ?? []),
45
+ selection,
46
+ ],
47
+ });
48
+ return { path, selection, configuration, replacing };
49
+ }
50
+ export async function applyProviderModelSelection(plan) {
51
+ const applied = await runMutationTransaction(async () => {
52
+ const fresh = await planProviderModelSelection(plan.selection, plan.path);
53
+ if (JSON.stringify(fresh.configuration) !==
54
+ JSON.stringify(plan.configuration))
55
+ throw new Error("Model configuration changed after preview; prepare the plan again.");
56
+ return { targets: [plan.path], value: fresh.configuration };
57
+ }, async (configuration) => {
58
+ await ensureDirectory(dirname(plan.path));
59
+ await writeFileAtomically(plan.path, `${JSON.stringify(configuration, null, 2)}\n`);
60
+ });
61
+ return applied.snapshotId;
62
+ }
63
+ export function formatProviderModelConfiguration(configuration) {
64
+ if (!configuration)
65
+ return "No provider model selections are configured.";
66
+ return [
67
+ `Model selections: ${configuration.selections.length}`,
68
+ ...configuration.selections.map((selection) => `${selection.id} — ${selection.provider}/${selection.model} — credential:${selection.credential
69
+ ? selection.credential.kind === "environment"
70
+ ? `environment:${selection.credential.name}`
71
+ : `os-keychain:${selection.credential.service}`
72
+ : "missing"} — agents:${selection.targetAgents?.join(",") ?? "any"}`),
73
+ ].join("\n");
74
+ }
75
+ /**
76
+ * OpenRouter adapter. Credentials are resolved just-in-time by the caller and
77
+ * exist only in the outbound request; this module never persists or logs them.
78
+ */
79
+ export async function requestOpenRouter(configuration, selectionId, messages, options) {
80
+ const selection = configuration.selections.find((item) => item.id === selectionId);
81
+ if (!selection)
82
+ throw new Error(`Unknown model selection '${selectionId}'`);
83
+ if (selection.provider !== "openrouter")
84
+ throw new Error(`Provider '${selection.provider}' has no adapter in this build`);
85
+ if (!selection.credential)
86
+ throw new Error("OpenRouter selection requires a credential reference");
87
+ const token = await options.resolveCredential(selection.credential);
88
+ if (!token)
89
+ throw new Error("OpenRouter credential reference did not resolve");
90
+ const response = await (options.fetcher ?? fetch)(`${selection.endpoint.replace(/\/$/, "")}/chat/completions`, {
91
+ method: "POST",
92
+ headers: {
93
+ accept: "application/json",
94
+ "content-type": "application/json",
95
+ authorization: `Bearer ${token}`,
96
+ },
97
+ body: JSON.stringify({ model: selection.model, messages }),
98
+ });
99
+ if (!response.ok)
100
+ throw new Error(`OpenRouter request failed (${response.status})`);
101
+ return response.json();
102
+ }
@@ -0,0 +1,87 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fetchGitHubReleaseMetadata, } from "./github.js";
4
+ import { writeFileAtomically } from "./atomic-file.js";
5
+ import { loadoutHome } from "./paths.js";
6
+ const MAX_OBSERVATIONS_PER_REPOSITORY = 90;
7
+ function observationPath(repository) {
8
+ return join(loadoutHome(), "observations", `${repository.replace("/", "__")}.json`);
9
+ }
10
+ export async function readCatalogObservations(repository) {
11
+ try {
12
+ const value = JSON.parse(await readFile(observationPath(repository), "utf8"));
13
+ if (!Array.isArray(value))
14
+ return [];
15
+ return value.filter((item) => Boolean(item) &&
16
+ typeof item === "object" &&
17
+ typeof item.repository === "string" &&
18
+ typeof item.observedAt === "string" &&
19
+ typeof item.stars === "number" &&
20
+ typeof item.latestReleaseDownloads === "number");
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ /**
27
+ * Persist one daily-like observation per repository. Existing observations on
28
+ * the same UTC date are replaced, so repeated refreshes do not fake velocity.
29
+ */
30
+ export async function recordCatalogObservations(packages, options = {}) {
31
+ const failures = [];
32
+ let recorded = 0;
33
+ const observedAt = new Date().toISOString();
34
+ const day = observedAt.slice(0, 10);
35
+ for (const pkg of packages) {
36
+ if (typeof pkg.stars !== "number")
37
+ continue;
38
+ try {
39
+ const release = await fetchGitHubReleaseMetadata(pkg.repository, options);
40
+ const observation = {
41
+ repository: pkg.repository,
42
+ observedAt,
43
+ stars: pkg.stars,
44
+ latestReleaseTag: release.tag,
45
+ latestReleasePublishedAt: release.publishedAt,
46
+ latestReleaseDownloads: release.downloadCount,
47
+ };
48
+ const prior = await readCatalogObservations(pkg.repository);
49
+ const retained = prior.filter((item) => item.observedAt.slice(0, 10) !== day);
50
+ retained.push(observation);
51
+ retained.sort((left, right) => left.observedAt.localeCompare(right.observedAt));
52
+ await writeFileAtomically(observationPath(pkg.repository), `${JSON.stringify(retained.slice(-MAX_OBSERVATIONS_PER_REPOSITORY), null, 2)}\n`);
53
+ recorded++;
54
+ }
55
+ catch (error) {
56
+ failures.push({
57
+ repository: pkg.repository,
58
+ error: error instanceof Error ? error.message : String(error),
59
+ });
60
+ }
61
+ }
62
+ return { recorded, failures };
63
+ }
64
+ const SPARKS = "▁▂▃▄▅▆▇█";
65
+ /** A compact terminal chart. One observation cannot establish a trend. */
66
+ export function formatStarHistory(observations) {
67
+ if (!observations.length)
68
+ return "No observations yet. Run `loadout catalog --refresh`.";
69
+ const values = observations.map((item) => item.stars);
70
+ const minimum = Math.min(...values);
71
+ const maximum = Math.max(...values);
72
+ const chart = values
73
+ .map((value) => {
74
+ if (maximum === minimum)
75
+ return SPARKS[0];
76
+ return SPARKS[Math.round(((value - minimum) / (maximum - minimum)) * (SPARKS.length - 1))];
77
+ })
78
+ .join("");
79
+ const latest = observations.at(-1);
80
+ const previous = observations.at(-2);
81
+ const delta = previous ? latest.stars - previous.stars : undefined;
82
+ return [
83
+ `${latest.repository} ${chart}`,
84
+ `stars ${latest.stars}${delta === undefined ? " (first observation)" : ` (${delta >= 0 ? "+" : ""}${delta} since previous)`}`,
85
+ `latest release ${latest.latestReleaseTag ?? "none"} · downloads ${latest.latestReleaseDownloads}`,
86
+ ].join("\n");
87
+ }
@@ -0,0 +1,164 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { writeFileAtomically } from "./atomic-file.js";
5
+ import { ensureDirectory, loadoutHome } from "./paths.js";
6
+ import { withFileLock } from "./file-lock.js";
7
+ const outcomePath = () => join(loadoutHome(), "outcomes.json");
8
+ const outcomeLockPath = () => join(loadoutHome(), "outcomes.lock");
9
+ const RESULTS = new Set([
10
+ "accept",
11
+ "reject",
12
+ "success",
13
+ "failure",
14
+ "activation",
15
+ "disable",
16
+ "rollback",
17
+ ]);
18
+ const TASKS = new Set([
19
+ "general",
20
+ "frontend",
21
+ "testing",
22
+ "javascript",
23
+ "python",
24
+ "backend",
25
+ "security",
26
+ "documentation",
27
+ ]);
28
+ const AGENTS = new Set([
29
+ "claude-code",
30
+ "codex",
31
+ "cursor",
32
+ "gemini-cli",
33
+ "opencode",
34
+ "hermes",
35
+ "windsurf",
36
+ "cline",
37
+ "github-copilot",
38
+ "roo-code",
39
+ "kiro-cli",
40
+ "junie",
41
+ ]);
42
+ function validEvent(value) {
43
+ if (!value || typeof value !== "object" || Array.isArray(value))
44
+ return false;
45
+ const event = value;
46
+ return (typeof event.id === "string" &&
47
+ typeof event.recordedAt === "string" &&
48
+ typeof event.selector === "string" &&
49
+ AGENTS.has(event.agent) &&
50
+ TASKS.has(event.taskFamily) &&
51
+ RESULTS.has(event.result));
52
+ }
53
+ export async function readLocalOutcomes() {
54
+ try {
55
+ const value = JSON.parse(await readFile(outcomePath(), "utf8"));
56
+ if (!value ||
57
+ typeof value !== "object" ||
58
+ Array.isArray(value) ||
59
+ value.schemaVersion !== 1 ||
60
+ !Array.isArray(value.events) ||
61
+ !value.events.every(validEvent))
62
+ throw new Error("outcome schema is invalid");
63
+ return value;
64
+ }
65
+ catch (error) {
66
+ if (error &&
67
+ typeof error === "object" &&
68
+ "code" in error &&
69
+ error.code === "ENOENT")
70
+ return {
71
+ schemaVersion: 1,
72
+ events: [],
73
+ privacy: "local-only-no-project-or-content",
74
+ };
75
+ throw new Error(`Loadout outcomes are invalid at ${outcomePath()}: ${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ }
78
+ function validateSelector(selector) {
79
+ if (!/^[a-z0-9][a-z0-9._-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(selector))
80
+ throw new Error("Outcome selector must be package/skill, not a path");
81
+ }
82
+ export async function recordLocalOutcome(event, now = new Date()) {
83
+ validateSelector(event.selector);
84
+ if (!TASKS.has(event.taskFamily))
85
+ throw new Error(`Unknown task family: ${event.taskFamily}`);
86
+ if (!RESULTS.has(event.result))
87
+ throw new Error(`Unknown outcome result: ${event.result}`);
88
+ return withFileLock(outcomeLockPath(), async () => {
89
+ const store = await readLocalOutcomes();
90
+ const recorded = {
91
+ ...event,
92
+ id: randomUUID(),
93
+ recordedAt: now.toISOString(),
94
+ };
95
+ store.events = [...store.events.slice(-999), recorded];
96
+ await ensureDirectory(dirname(outcomePath()));
97
+ await writeFileAtomically(outcomePath(), `${JSON.stringify(store, null, 2)}\n`);
98
+ return recorded;
99
+ });
100
+ }
101
+ export function projectTaskFamilies(project) {
102
+ const families = new Set(["general"]);
103
+ if (project.languages.includes("javascript/typescript"))
104
+ families.add("javascript");
105
+ if (project.languages.includes("python"))
106
+ families.add("python");
107
+ if (project.languages.some((item) => ["go", "rust", "java", ".net"].includes(item)))
108
+ families.add("backend");
109
+ if (project.frameworks.length)
110
+ families.add("frontend");
111
+ if (project.frameworks.includes("playwright"))
112
+ families.add("testing");
113
+ return [...families];
114
+ }
115
+ export function outcomeAdjustment(store, selector, agent, taskFamilies) {
116
+ const relevant = store.events.filter((event) => event.selector === selector &&
117
+ event.agent === agent &&
118
+ (event.taskFamily === "general" ||
119
+ taskFamilies.includes(event.taskFamily)));
120
+ const weights = {
121
+ accept: 18,
122
+ reject: -30,
123
+ success: 15,
124
+ failure: -25,
125
+ activation: 2,
126
+ disable: -8,
127
+ rollback: -35,
128
+ };
129
+ const raw = relevant.reduce((total, event) => total + weights[event.result], 0);
130
+ const score = Math.max(-60, Math.min(60, raw));
131
+ return {
132
+ score,
133
+ evidence: relevant.length
134
+ ? [
135
+ `${relevant.length} local outcome(s) in the same agent/task scope: ${score >= 0 ? "+" : ""}${score}`,
136
+ ]
137
+ : [],
138
+ };
139
+ }
140
+ /** Aggregate package/skill outcomes without retaining project paths or content. */
141
+ export function packageOutcomeAdjustment(store, packageId, agent, taskFamilies) {
142
+ const events = store.events.filter((event) => event.selector.startsWith(`${packageId}/`) &&
143
+ event.agent === agent &&
144
+ (event.taskFamily === "general" ||
145
+ taskFamilies.includes(event.taskFamily)));
146
+ const weights = {
147
+ accept: 18,
148
+ reject: -30,
149
+ success: 15,
150
+ failure: -25,
151
+ activation: 2,
152
+ disable: -8,
153
+ rollback: -35,
154
+ };
155
+ const score = Math.max(-60, Math.min(60, events.reduce((total, event) => total + weights[event.result], 0)));
156
+ return {
157
+ score,
158
+ evidence: events.length
159
+ ? [
160
+ `${events.length} local-only package outcome(s) for ${agent} in the same task scope: ${score >= 0 ? "+" : ""}${score}`,
161
+ ]
162
+ : [],
163
+ };
164
+ }