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,233 @@
|
|
|
1
|
+
import { applyActivationChange, formatActivationPlan, planActivationChange, } from "./active-set.js";
|
|
2
|
+
import { scanProject } from "./recommend.js";
|
|
3
|
+
import { readInstallState } from "./state.js";
|
|
4
|
+
import { outcomeAdjustment, projectTaskFamilies, readLocalOutcomes, } from "./outcomes.js";
|
|
5
|
+
const FOUNDATION = new Set([
|
|
6
|
+
"brainstorming",
|
|
7
|
+
"systematic-debugging",
|
|
8
|
+
"test-driven-development",
|
|
9
|
+
"verification-before-completion",
|
|
10
|
+
"writing-plans",
|
|
11
|
+
"requesting-code-review",
|
|
12
|
+
"receiving-code-review",
|
|
13
|
+
"find-docs",
|
|
14
|
+
"context7-docs",
|
|
15
|
+
"security-best-practices",
|
|
16
|
+
"security-threat-model",
|
|
17
|
+
"code-review-excellence",
|
|
18
|
+
"api-design-principles",
|
|
19
|
+
"architecture-patterns",
|
|
20
|
+
"accessibility-compliance",
|
|
21
|
+
"deployment-pipeline-design",
|
|
22
|
+
"documentation-writer",
|
|
23
|
+
"openai-docs",
|
|
24
|
+
"web-design-guidelines",
|
|
25
|
+
]);
|
|
26
|
+
const SPECIALIZED_WITHOUT_SIGNAL = /(?:appstore|backtesting|bats-|oracle|salesforce|power-bi|dataverse|qdrant|azure-|aws-|mcp-server-generator|sandbox-npm|migration)/;
|
|
27
|
+
const SOURCE_PRIORITY = {
|
|
28
|
+
superpowers: 80,
|
|
29
|
+
context7: 75,
|
|
30
|
+
"openai-skills": 70,
|
|
31
|
+
"anthropic-skills": 70,
|
|
32
|
+
"vercel-agent-skills": 60,
|
|
33
|
+
"ui-ux-pro-max": 55,
|
|
34
|
+
"wshobson-agents": 45,
|
|
35
|
+
"openai-codex-skills": 40,
|
|
36
|
+
"awesome-copilot": 20,
|
|
37
|
+
};
|
|
38
|
+
const GENERAL = [
|
|
39
|
+
[/(?:security|threat|secrets|owasp)/, "security"],
|
|
40
|
+
[/(?:review|refactor)/, "code review"],
|
|
41
|
+
[/(?:debug|diagnos)/, "debugging"],
|
|
42
|
+
[/(?:test|playwright|coverage)/, "testing"],
|
|
43
|
+
[/(?:architect|api-design|openapi)/, "architecture"],
|
|
44
|
+
[/(?:docs|documentation|readme)/, "documentation"],
|
|
45
|
+
[/(?:github-actions|deployment-pipeline)/, "delivery"],
|
|
46
|
+
];
|
|
47
|
+
const SIGNAL_RULES = [
|
|
48
|
+
{
|
|
49
|
+
signal: (project) => project.languages.includes("javascript/typescript"),
|
|
50
|
+
pattern: /(?:javascript|typescript|nodejs|npm|webapp)/,
|
|
51
|
+
label: "JavaScript/TypeScript project",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
signal: (project) => project.languages.includes("python"),
|
|
55
|
+
pattern: /(?:python|pytest|fastapi|jupyter)/,
|
|
56
|
+
label: "Python project",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
signal: (project) => project.languages.includes("go"),
|
|
60
|
+
pattern: /(?:^|-)go(?:-|$)|golang/,
|
|
61
|
+
label: "Go project",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
signal: (project) => project.languages.includes("rust"),
|
|
65
|
+
pattern: /rust|cargo/,
|
|
66
|
+
label: "Rust project",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
signal: (project) => project.languages.includes("java"),
|
|
70
|
+
pattern: /java|spring/,
|
|
71
|
+
label: "Java project",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
signal: (project) => project.languages.includes(".net"),
|
|
75
|
+
pattern: /dotnet|csharp|aspnet/,
|
|
76
|
+
label: ".NET project",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
signal: (project) => project.frameworks.some((item) => ["react", "next.js", "vue", "svelte"].includes(item)),
|
|
80
|
+
pattern: /react|nextjs|frontend|web-design|ui-|design-system|responsive|accessibility|figma/,
|
|
81
|
+
label: "frontend framework",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
signal: (project) => project.frameworks.includes("playwright"),
|
|
85
|
+
pattern: /playwright|e2e|webapp-testing/,
|
|
86
|
+
label: "Playwright project",
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
function selector(record) {
|
|
90
|
+
if (!record.unitId)
|
|
91
|
+
return undefined;
|
|
92
|
+
return `${record.packageId}/${record.unitId}`;
|
|
93
|
+
}
|
|
94
|
+
function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
|
|
95
|
+
const id = selector(record);
|
|
96
|
+
if (!id || !record.unitId)
|
|
97
|
+
return undefined;
|
|
98
|
+
const name = record.unitId.toLowerCase();
|
|
99
|
+
let score = 0;
|
|
100
|
+
const reasons = [];
|
|
101
|
+
if (pinned.has(id) || pinned.has(record.unitId)) {
|
|
102
|
+
score += 1000;
|
|
103
|
+
reasons.push("explicitly pinned");
|
|
104
|
+
}
|
|
105
|
+
const explicitlyPinned = score >= 1000;
|
|
106
|
+
if (!explicitlyPinned && SPECIALIZED_WITHOUT_SIGNAL.test(name))
|
|
107
|
+
return undefined;
|
|
108
|
+
if (FOUNDATION.has(name)) {
|
|
109
|
+
score += 100;
|
|
110
|
+
reasons.push("cross-project foundation");
|
|
111
|
+
}
|
|
112
|
+
for (const [pattern, label] of GENERAL)
|
|
113
|
+
if (pattern.test(name)) {
|
|
114
|
+
score += 18;
|
|
115
|
+
reasons.push(label);
|
|
116
|
+
}
|
|
117
|
+
for (const rule of SIGNAL_RULES)
|
|
118
|
+
if (rule.signal(project) && rule.pattern.test(name)) {
|
|
119
|
+
score += 55;
|
|
120
|
+
reasons.push(rule.label);
|
|
121
|
+
}
|
|
122
|
+
const local = outcomeAdjustment(outcomes, id, record.agent, taskFamilies);
|
|
123
|
+
if (!explicitlyPinned && local.score <= -35)
|
|
124
|
+
return undefined;
|
|
125
|
+
score += local.score;
|
|
126
|
+
reasons.push(...local.evidence);
|
|
127
|
+
// Capacity is a ceiling, never a quota. A generic word such as "test" or
|
|
128
|
+
// "review" is supporting evidence, but cannot activate a niche skill alone.
|
|
129
|
+
if (score < 50)
|
|
130
|
+
return undefined;
|
|
131
|
+
return {
|
|
132
|
+
selector: id,
|
|
133
|
+
packageId: record.packageId,
|
|
134
|
+
unitId: record.unitId,
|
|
135
|
+
score,
|
|
136
|
+
reasons: [...new Set(reasons)],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Deterministic, local-only active-set selection over the reviewed library. */
|
|
140
|
+
export async function planProjectActivation(projectPath, options = {}) {
|
|
141
|
+
const limit = options.limit ?? 40;
|
|
142
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
143
|
+
throw new Error("--limit must be an integer from 1 to 200");
|
|
144
|
+
const project = await scanProject(projectPath);
|
|
145
|
+
const state = await readInstallState();
|
|
146
|
+
const outcomes = await readLocalOutcomes();
|
|
147
|
+
const taskFamilies = projectTaskFamilies(project);
|
|
148
|
+
const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
|
|
149
|
+
(!options.agents || options.agents.includes(record.agent)));
|
|
150
|
+
const byAgent = new Map();
|
|
151
|
+
for (const record of relevant)
|
|
152
|
+
if (record.activationState === "active")
|
|
153
|
+
byAgent.set(record.agent, (byAgent.get(record.agent) ?? 0) + 1);
|
|
154
|
+
const activeBefore = Math.max(0, ...byAgent.values());
|
|
155
|
+
const capacity = Math.max(0, limit - activeBefore);
|
|
156
|
+
const pins = new Set(options.pins ?? []);
|
|
157
|
+
const scored = relevant
|
|
158
|
+
.filter((record) => record.activationState === "disabled" &&
|
|
159
|
+
record.cacheState === "downloaded" &&
|
|
160
|
+
record.reviewState === "reviewed")
|
|
161
|
+
.map((record) => scoreCandidate(record, project, pins, outcomes, taskFamilies))
|
|
162
|
+
.filter((item) => Boolean(item))
|
|
163
|
+
.sort((left, right) => right.score - left.score ||
|
|
164
|
+
(SOURCE_PRIORITY[right.packageId] ?? 0) -
|
|
165
|
+
(SOURCE_PRIORITY[left.packageId] ?? 0) ||
|
|
166
|
+
left.unitId.localeCompare(right.unitId) ||
|
|
167
|
+
left.packageId.localeCompare(right.packageId));
|
|
168
|
+
// One capability name wins by evidence order; overlapping collections remain
|
|
169
|
+
// in the library and can still be explicitly pinned by full selector.
|
|
170
|
+
const unique = [];
|
|
171
|
+
const claimedUnits = new Set();
|
|
172
|
+
for (const item of scored) {
|
|
173
|
+
const fullPin = pins.has(item.selector);
|
|
174
|
+
if (!fullPin && claimedUnits.has(item.unitId))
|
|
175
|
+
continue;
|
|
176
|
+
claimedUnits.add(item.unitId);
|
|
177
|
+
unique.push(item);
|
|
178
|
+
}
|
|
179
|
+
const selected = unique.slice(0, capacity);
|
|
180
|
+
const alternatives = selected.flatMap((chosen) => {
|
|
181
|
+
const deferred = scored
|
|
182
|
+
.filter((candidate) => candidate.unitId === chosen.unitId &&
|
|
183
|
+
candidate.selector !== chosen.selector)
|
|
184
|
+
.map((candidate) => candidate.selector);
|
|
185
|
+
return deferred.length
|
|
186
|
+
? [{ unitId: chosen.unitId, selected: chosen.selector, deferred }]
|
|
187
|
+
: [];
|
|
188
|
+
});
|
|
189
|
+
const warnings = [];
|
|
190
|
+
const known = new Set(relevant.flatMap((record) => {
|
|
191
|
+
const id = selector(record);
|
|
192
|
+
return id ? [id, record.unitId] : [];
|
|
193
|
+
}));
|
|
194
|
+
const unknownPins = [...pins].filter((pin) => !known.has(pin));
|
|
195
|
+
if (unknownPins.length)
|
|
196
|
+
warnings.push(`Pinned skill(s) are not in the managed library: ${unknownPins.join(", ")}`);
|
|
197
|
+
if (!capacity)
|
|
198
|
+
warnings.push(`Active-set limit ${limit} is already reached; disable skills or raise --limit.`);
|
|
199
|
+
const activation = selected.length
|
|
200
|
+
? await planActivationChange("enable", selected.map((item) => item.selector), { ...(options.agents ? { agents: options.agents } : {}) })
|
|
201
|
+
: undefined;
|
|
202
|
+
return {
|
|
203
|
+
project,
|
|
204
|
+
limit,
|
|
205
|
+
...(options.agents ? { agents: options.agents } : {}),
|
|
206
|
+
activeBefore,
|
|
207
|
+
capacity,
|
|
208
|
+
selected,
|
|
209
|
+
alternatives,
|
|
210
|
+
...(activation ? { activation } : {}),
|
|
211
|
+
warnings,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export async function applyProjectActivation(plan) {
|
|
215
|
+
if (!plan.activation || !plan.selected.length)
|
|
216
|
+
throw new Error("No reviewed library skills were selected for activation");
|
|
217
|
+
return applyActivationChange(plan.activation);
|
|
218
|
+
}
|
|
219
|
+
export function formatProjectActivation(plan) {
|
|
220
|
+
const detected = [...plan.project.languages, ...plan.project.frameworks];
|
|
221
|
+
return [
|
|
222
|
+
`Project: ${plan.project.root}`,
|
|
223
|
+
`Detected: ${detected.join(", ") || "no known project signals"}`,
|
|
224
|
+
`Active-set budget: ${plan.activeBefore}/${plan.limit} active; ${plan.capacity} slot(s) available`,
|
|
225
|
+
`Proposed additions: ${plan.selected.length}`,
|
|
226
|
+
...plan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
|
|
227
|
+
...plan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
|
|
228
|
+
...(plan.activation
|
|
229
|
+
? ["", "Exact activation delta:", formatActivationPlan(plan.activation)]
|
|
230
|
+
: []),
|
|
231
|
+
...plan.warnings.map((warning) => `Warning: ${warning}`),
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { agentSkillsDirectory, ensureDirectory } from "./paths.js";
|
|
5
|
+
import { activationLibraryPath, installStatePath, readInstallState, writeInstallState, } from "./state.js";
|
|
6
|
+
import { runMutationTransaction } from "./transaction.js";
|
|
7
|
+
/** Resolve a managed file to its active location or disabled library copy. */
|
|
8
|
+
export function managedFileReadPath(packageId, filePath, activations) {
|
|
9
|
+
const disabled = activations.find((activation) => activation.packageId === packageId &&
|
|
10
|
+
activation.installationState === "installed" &&
|
|
11
|
+
activation.activationState === "disabled" &&
|
|
12
|
+
activation.targets.some((target) => isInside(target.activePath, filePath)));
|
|
13
|
+
if (!disabled)
|
|
14
|
+
return filePath;
|
|
15
|
+
const target = disabled.targets.find((item) => isInside(item.activePath, filePath));
|
|
16
|
+
return join(disabled.libraryPath, target.libraryRelativePath, relative(target.activePath, filePath));
|
|
17
|
+
}
|
|
18
|
+
function isInside(root, candidate) {
|
|
19
|
+
const path = relative(resolve(root), resolve(candidate));
|
|
20
|
+
return path === "" || (!path.startsWith("..") && !path.startsWith(sep));
|
|
21
|
+
}
|
|
22
|
+
async function pathExists(path) {
|
|
23
|
+
try {
|
|
24
|
+
await lstat(path);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error &&
|
|
29
|
+
typeof error === "object" &&
|
|
30
|
+
"code" in error &&
|
|
31
|
+
error.code === "ENOENT")
|
|
32
|
+
return false;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function hashTree(root) {
|
|
37
|
+
const files = [];
|
|
38
|
+
async function visit(path) {
|
|
39
|
+
const info = await lstat(path);
|
|
40
|
+
if (info.isSymbolicLink())
|
|
41
|
+
throw new Error(`Refusing symlink in managed active set: ${path}`);
|
|
42
|
+
if (info.isFile()) {
|
|
43
|
+
files.push({
|
|
44
|
+
path: relative(root, path).split(sep).join("/"),
|
|
45
|
+
sha256: createHash("sha256")
|
|
46
|
+
.update(await readFile(path))
|
|
47
|
+
.digest("hex"),
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!info.isDirectory())
|
|
52
|
+
throw new Error(`Refusing unsupported managed entry: ${path}`);
|
|
53
|
+
for (const entry of await readdir(path))
|
|
54
|
+
await visit(join(path, entry));
|
|
55
|
+
}
|
|
56
|
+
if (!(await pathExists(root)))
|
|
57
|
+
return [];
|
|
58
|
+
await visit(root);
|
|
59
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
60
|
+
}
|
|
61
|
+
function legacyTargets(record, agent) {
|
|
62
|
+
const root = agentSkillsDirectory(agent);
|
|
63
|
+
const withinAgent = record.files
|
|
64
|
+
.filter((file) => isInside(root, file.path) && basename(file.path) === "SKILL.md")
|
|
65
|
+
.map((file) => dirname(file.path));
|
|
66
|
+
if (withinAgent.length)
|
|
67
|
+
return [...new Set(withinAgent)].sort();
|
|
68
|
+
if (record.targetAgents.length === 1)
|
|
69
|
+
return [
|
|
70
|
+
...new Set(record.files
|
|
71
|
+
.filter((file) => basename(file.path) === "SKILL.md")
|
|
72
|
+
.map((file) => dirname(file.path))),
|
|
73
|
+
].sort();
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
function legacyActivations(installs, existing) {
|
|
77
|
+
const keys = new Set(existing.map((entry) => `${entry.packageId}\0${entry.agent}`));
|
|
78
|
+
return installs.flatMap((install) => install.targetAgents.flatMap((agent) => {
|
|
79
|
+
if (keys.has(`${install.packageId}\0${agent}`))
|
|
80
|
+
return [];
|
|
81
|
+
const targets = legacyTargets(install, agent);
|
|
82
|
+
if (!targets.length)
|
|
83
|
+
return [];
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
packageId: install.packageId,
|
|
87
|
+
agent,
|
|
88
|
+
cacheState: "missing",
|
|
89
|
+
reviewState: "unreviewed",
|
|
90
|
+
installationState: "installed",
|
|
91
|
+
activationState: "active",
|
|
92
|
+
libraryPath: activationLibraryPath(install.packageId, agent),
|
|
93
|
+
targets: targets.map((activePath) => ({
|
|
94
|
+
activePath,
|
|
95
|
+
libraryRelativePath: basename(activePath),
|
|
96
|
+
})),
|
|
97
|
+
libraryFiles: [],
|
|
98
|
+
updatedAt: install.installedAt,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
function allActivationRecords(installs, existing) {
|
|
104
|
+
return [...existing, ...legacyActivations(installs, existing)].sort((left, right) => left.packageId.localeCompare(right.packageId) ||
|
|
105
|
+
left.agent.localeCompare(right.agent) ||
|
|
106
|
+
(left.unitId ?? "").localeCompare(right.unitId ?? ""));
|
|
107
|
+
}
|
|
108
|
+
function exactTreeDifferences(expected, actual) {
|
|
109
|
+
const expectedMap = new Map(expected.map((file) => [file.path, file.sha256]));
|
|
110
|
+
const actualMap = new Map(actual.map((file) => [file.path, file.sha256]));
|
|
111
|
+
const missing = [...expectedMap].filter(([path]) => !actualMap.has(path));
|
|
112
|
+
const modified = [...expectedMap].filter(([path, digest]) => actualMap.get(path) && actualMap.get(path) !== digest);
|
|
113
|
+
const extra = [...actualMap].filter(([path]) => !expectedMap.has(path));
|
|
114
|
+
return [
|
|
115
|
+
...(missing.length
|
|
116
|
+
? [`${missing.length} expected file(s) are missing`]
|
|
117
|
+
: []),
|
|
118
|
+
...(modified.length ? [`${modified.length} managed file(s) changed`] : []),
|
|
119
|
+
...(extra.length
|
|
120
|
+
? [`${extra.length} untracked file(s) would be affected`]
|
|
121
|
+
: []),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
function expectedActiveFiles(install, targets) {
|
|
125
|
+
return install.files
|
|
126
|
+
.filter((file) => targets.some((target) => isInside(target.activePath, file.path)))
|
|
127
|
+
.map((file) => ({ path: resolve(file.path), sha256: file.sha256 }))
|
|
128
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
129
|
+
}
|
|
130
|
+
function expectedLibraryFiles(install, targets) {
|
|
131
|
+
return install.files
|
|
132
|
+
.flatMap((file) => {
|
|
133
|
+
const target = targets.find((item) => isInside(item.activePath, file.path));
|
|
134
|
+
if (!target)
|
|
135
|
+
return [];
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
path: join(target.libraryRelativePath, relative(target.activePath, file.path))
|
|
139
|
+
.split(sep)
|
|
140
|
+
.join("/"),
|
|
141
|
+
sha256: file.sha256,
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
})
|
|
145
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
146
|
+
}
|
|
147
|
+
async function activeTreeFiles(targets) {
|
|
148
|
+
const files = await Promise.all(targets.map(async (target) => (await hashTree(target.activePath)).map((file) => ({
|
|
149
|
+
path: resolve(target.activePath, file.path),
|
|
150
|
+
sha256: file.sha256,
|
|
151
|
+
}))));
|
|
152
|
+
return files
|
|
153
|
+
.flat()
|
|
154
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
155
|
+
}
|
|
156
|
+
export async function buildLibraryStateReport() {
|
|
157
|
+
const state = await readInstallState();
|
|
158
|
+
const records = allActivationRecords(state.installs, state.activations ?? []);
|
|
159
|
+
return {
|
|
160
|
+
generatedAt: new Date().toISOString(),
|
|
161
|
+
records,
|
|
162
|
+
counts: {
|
|
163
|
+
packages: new Set(records.map((record) => record.packageId)).size,
|
|
164
|
+
downloaded: records.filter((record) => record.cacheState === "downloaded")
|
|
165
|
+
.length,
|
|
166
|
+
reviewed: records.filter((record) => record.reviewState === "reviewed")
|
|
167
|
+
.length,
|
|
168
|
+
quarantined: records.filter((record) => record.reviewState === "quarantined").length,
|
|
169
|
+
installed: records.filter((record) => record.installationState === "installed").length,
|
|
170
|
+
active: records.filter((record) => record.activationState === "active")
|
|
171
|
+
.length,
|
|
172
|
+
disabled: records.filter((record) => record.activationState === "disabled").length,
|
|
173
|
+
removed: records.filter((record) => record.installationState === "removed").length,
|
|
174
|
+
},
|
|
175
|
+
migrationPending: records.filter((record) => record.installationState === "installed" &&
|
|
176
|
+
record.cacheState === "missing").length,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export async function planActivationChange(action, packageIds, options = {}) {
|
|
180
|
+
const packages = [
|
|
181
|
+
...new Set(packageIds.map((id) => id.trim()).filter(Boolean)),
|
|
182
|
+
];
|
|
183
|
+
if (!packages.length)
|
|
184
|
+
throw new Error("At least one package id is required");
|
|
185
|
+
const state = await readInstallState();
|
|
186
|
+
const installs = new Map(state.installs.map((install) => [install.packageId, install]));
|
|
187
|
+
const selectors = packages.map((selector) => {
|
|
188
|
+
const separator = selector.indexOf("/");
|
|
189
|
+
return {
|
|
190
|
+
selector,
|
|
191
|
+
packageId: separator < 0 ? selector : selector.slice(0, separator),
|
|
192
|
+
unitId: separator < 0 ? undefined : selector.slice(separator + 1),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
for (const selector of selectors)
|
|
196
|
+
if (!installs.has(selector.packageId))
|
|
197
|
+
throw new Error(`Package is not managed by Loadout: ${selector.packageId}`);
|
|
198
|
+
const records = allActivationRecords(state.installs, state.activations ?? []);
|
|
199
|
+
const changes = [];
|
|
200
|
+
const skipped = [];
|
|
201
|
+
for (const selector of selectors) {
|
|
202
|
+
const { packageId, unitId } = selector;
|
|
203
|
+
const candidates = records.filter((record) => record.packageId === packageId &&
|
|
204
|
+
(!unitId || record.unitId === unitId) &&
|
|
205
|
+
(!options.agents || options.agents.includes(record.agent)));
|
|
206
|
+
if (!candidates.length)
|
|
207
|
+
throw new Error(`No managed skill activation for '${selector.selector}' matches the requested agents`);
|
|
208
|
+
for (const record of candidates) {
|
|
209
|
+
const desired = action === "enable" ? "active" : "disabled";
|
|
210
|
+
if (record.activationState === desired) {
|
|
211
|
+
skipped.push({
|
|
212
|
+
packageId,
|
|
213
|
+
...(record.unitId ? { unitId: record.unitId } : {}),
|
|
214
|
+
agent: record.agent,
|
|
215
|
+
reason: `already ${desired}`,
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const change = {
|
|
220
|
+
packageId,
|
|
221
|
+
...(record.unitId ? { unitId: record.unitId } : {}),
|
|
222
|
+
agent: record.agent,
|
|
223
|
+
from: record.activationState,
|
|
224
|
+
to: desired,
|
|
225
|
+
libraryPath: record.libraryPath,
|
|
226
|
+
targets: record.targets,
|
|
227
|
+
blockers: [],
|
|
228
|
+
};
|
|
229
|
+
const install = installs.get(packageId);
|
|
230
|
+
if (resolve(record.libraryPath) !==
|
|
231
|
+
resolve(activationLibraryPath(packageId, record.agent, record.unitId)))
|
|
232
|
+
change.blockers.push("the library path does not match Loadout's managed path");
|
|
233
|
+
const validRoots = new Set(install.files
|
|
234
|
+
.filter((file) => basename(file.path) === "SKILL.md")
|
|
235
|
+
.map((file) => resolve(dirname(file.path))));
|
|
236
|
+
if (change.targets.some((target) => !validRoots.has(resolve(target.activePath)) ||
|
|
237
|
+
target.libraryRelativePath !== basename(target.activePath) ||
|
|
238
|
+
target.libraryRelativePath === "." ||
|
|
239
|
+
target.libraryRelativePath === ".." ||
|
|
240
|
+
/[\\/]/.test(target.libraryRelativePath)))
|
|
241
|
+
change.blockers.push("one or more activation targets are not backed by the managed install record");
|
|
242
|
+
if (new Set(change.targets.map((target) => target.libraryRelativePath))
|
|
243
|
+
.size !== change.targets.length)
|
|
244
|
+
change.blockers.push("activation targets collide inside the library copy");
|
|
245
|
+
if (record.reviewState === "quarantined")
|
|
246
|
+
change.blockers.push("the reviewed library entry is quarantined");
|
|
247
|
+
if (record.installationState !== "installed")
|
|
248
|
+
change.blockers.push("the package has been removed");
|
|
249
|
+
if (action === "disable") {
|
|
250
|
+
const expected = expectedActiveFiles(install, change.targets);
|
|
251
|
+
const actual = await activeTreeFiles(change.targets);
|
|
252
|
+
change.blockers.push(...exactTreeDifferences(expected, actual));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
if (record.cacheState !== "downloaded" || !record.libraryFiles.length)
|
|
256
|
+
change.blockers.push("no complete reviewed-library copy is available");
|
|
257
|
+
else {
|
|
258
|
+
const libraryActual = await hashTree(record.libraryPath);
|
|
259
|
+
change.blockers.push(...exactTreeDifferences(record.libraryFiles, libraryActual).map((message) => `library copy: ${message}`));
|
|
260
|
+
}
|
|
261
|
+
const occupied = (await Promise.all(change.targets.map((target) => pathExists(target.activePath)))).filter(Boolean).length;
|
|
262
|
+
if (occupied)
|
|
263
|
+
change.blockers.push(`${occupied} active target(s) already exist and would be overwritten`);
|
|
264
|
+
}
|
|
265
|
+
changes.push(change);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const warnings = changes.flatMap((change) => change.blockers.map((blocker) => `${change.packageId}/${change.agent}: ${blocker}`));
|
|
269
|
+
return {
|
|
270
|
+
action,
|
|
271
|
+
packages,
|
|
272
|
+
...(options.agents ? { requestedAgents: options.agents } : {}),
|
|
273
|
+
changes,
|
|
274
|
+
skipped,
|
|
275
|
+
blocked: warnings.length > 0,
|
|
276
|
+
warnings,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function activationKey(record) {
|
|
280
|
+
return `${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`;
|
|
281
|
+
}
|
|
282
|
+
export async function applyActivationChange(plan) {
|
|
283
|
+
const applied = await runMutationTransaction(async () => {
|
|
284
|
+
const fresh = await planActivationChange(plan.action, plan.packages, {
|
|
285
|
+
...(plan.requestedAgents ? { agents: plan.requestedAgents } : {}),
|
|
286
|
+
});
|
|
287
|
+
if (fresh.blocked)
|
|
288
|
+
throw new Error(fresh.warnings.join("; "));
|
|
289
|
+
if (!fresh.changes.length)
|
|
290
|
+
throw new Error(`Nothing to ${plan.action}; every selected activation is already ${plan.action === "enable" ? "active" : "disabled"}`);
|
|
291
|
+
const state = await readInstallState();
|
|
292
|
+
const records = allActivationRecords(state.installs, state.activations ?? []);
|
|
293
|
+
const targets = [
|
|
294
|
+
...fresh.changes.flatMap((change) => change.targets.map((target) => target.activePath)),
|
|
295
|
+
...(fresh.action === "disable"
|
|
296
|
+
? fresh.changes.map((change) => change.libraryPath)
|
|
297
|
+
: []),
|
|
298
|
+
installStatePath(),
|
|
299
|
+
];
|
|
300
|
+
return { targets, value: { fresh, state, records } };
|
|
301
|
+
}, async ({ fresh, state, records }, snapshot) => {
|
|
302
|
+
const replacements = new Map();
|
|
303
|
+
const installs = new Map(state.installs.map((install) => [install.packageId, install]));
|
|
304
|
+
for (const change of fresh.changes) {
|
|
305
|
+
const current = records.find((record) => activationKey(record) === activationKey(change));
|
|
306
|
+
if (fresh.action === "disable") {
|
|
307
|
+
await rm(change.libraryPath, { recursive: true, force: true });
|
|
308
|
+
await ensureDirectory(change.libraryPath);
|
|
309
|
+
for (const target of change.targets) {
|
|
310
|
+
const destination = join(change.libraryPath, target.libraryRelativePath);
|
|
311
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
312
|
+
await cp(target.activePath, destination, {
|
|
313
|
+
recursive: true,
|
|
314
|
+
errorOnExist: true,
|
|
315
|
+
force: false,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const libraryFiles = await hashTree(change.libraryPath);
|
|
319
|
+
const copyDifferences = exactTreeDifferences(expectedLibraryFiles(installs.get(change.packageId), change.targets), libraryFiles);
|
|
320
|
+
if (copyDifferences.length)
|
|
321
|
+
throw new Error(`${change.packageId}/${change.agent}: copied library verification failed: ${copyDifferences.join("; ")}`);
|
|
322
|
+
for (const target of change.targets)
|
|
323
|
+
await rm(target.activePath, { recursive: true, force: true });
|
|
324
|
+
replacements.set(activationKey(change), {
|
|
325
|
+
...current,
|
|
326
|
+
cacheState: "downloaded",
|
|
327
|
+
activationState: "disabled",
|
|
328
|
+
libraryFiles,
|
|
329
|
+
updatedAt: new Date().toISOString(),
|
|
330
|
+
snapshotId: snapshot.id,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
for (const target of change.targets) {
|
|
335
|
+
const source = join(change.libraryPath, target.libraryRelativePath);
|
|
336
|
+
await mkdir(dirname(target.activePath), { recursive: true });
|
|
337
|
+
await cp(source, target.activePath, {
|
|
338
|
+
recursive: true,
|
|
339
|
+
errorOnExist: true,
|
|
340
|
+
force: false,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const activeFiles = await activeTreeFiles(change.targets);
|
|
344
|
+
const copyDifferences = exactTreeDifferences(expectedActiveFiles(installs.get(change.packageId), change.targets), activeFiles);
|
|
345
|
+
if (copyDifferences.length)
|
|
346
|
+
throw new Error(`${change.packageId}/${change.agent}: activated copy verification failed: ${copyDifferences.join("; ")}`);
|
|
347
|
+
replacements.set(activationKey(change), {
|
|
348
|
+
...current,
|
|
349
|
+
activationState: "active",
|
|
350
|
+
updatedAt: new Date().toISOString(),
|
|
351
|
+
snapshotId: snapshot.id,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
state.activations = records.map((record) => replacements.get(activationKey(record)) ?? record);
|
|
356
|
+
await writeInstallState(state);
|
|
357
|
+
});
|
|
358
|
+
return applied.snapshotId;
|
|
359
|
+
}
|
|
360
|
+
export function formatLibraryStateReport(report) {
|
|
361
|
+
if (!report.records.length)
|
|
362
|
+
return "No Loadout-managed skill activations exist.";
|
|
363
|
+
return [
|
|
364
|
+
`Managed library: ${report.counts.packages} package(s), ${report.counts.active} active, ${report.counts.disabled} disabled, ${report.counts.downloaded} cached`,
|
|
365
|
+
...(report.migrationPending
|
|
366
|
+
? [
|
|
367
|
+
`Migration pending: ${report.migrationPending} active installation(s) will be cached on first disable.`,
|
|
368
|
+
]
|
|
369
|
+
: []),
|
|
370
|
+
...report.records.map((record) => `${record.packageId}${record.unitId ? `/${record.unitId}` : ""} — ${record.agent} — cache:${record.cacheState} review:${record.reviewState} install:${record.installationState} activation:${record.activationState} — ${record.targets.length} target(s)`),
|
|
371
|
+
].join("\n");
|
|
372
|
+
}
|
|
373
|
+
export function formatActivationPlan(plan) {
|
|
374
|
+
return [
|
|
375
|
+
`${plan.action === "enable" ? "Enable" : "Disable"} plan: ${plan.changes.length} change(s), ${plan.skipped.length} already in desired state`,
|
|
376
|
+
...plan.changes.map((change) => `${change.packageId}${change.unitId ? `/${change.unitId}` : ""}/${change.agent}: ${change.from} -> ${change.to} (${change.targets.length} target(s))${change.blockers.length ? ` BLOCKED: ${change.blockers.join("; ")}` : ""}`),
|
|
377
|
+
...plan.skipped.map((item) => `${item.packageId}${item.unitId ? `/${item.unitId}` : ""}/${item.agent}: skipped (${item.reason})`),
|
|
378
|
+
...(plan.blocked
|
|
379
|
+
? ["No changes can be applied until every blocker is resolved."]
|
|
380
|
+
: []),
|
|
381
|
+
].join("\n");
|
|
382
|
+
}
|