loadout-ai 0.1.2 → 0.2.1
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/CHANGELOG.md +32 -0
- package/MASTER_PLAN.md +90 -8
- package/README.md +218 -330
- package/catalog/discovered.json +7925 -6183
- package/dist/src/cli.js +52 -27
- package/dist/src/core/access.js +42 -0
- package/dist/src/core/catalog-install.js +63 -13
- package/dist/src/core/health-score-evidence.js +8 -0
- package/dist/src/core/install.js +72 -39
- package/dist/src/core/mcp-recipes.js +37 -6
- package/dist/src/core/skills.js +20 -7
- package/dist/src/core/state.js +20 -4
- package/dist/src/core/update.js +111 -10
- package/dist/src/core/upgrade.js +1 -0
- package/dist/src/shared/schemas.js +8 -0
- package/docs/DISCOVERED.md +253 -253
- package/docs/FEATURE_TEST_MATRIX.md +8 -7
- package/docs/TESTING.md +44 -2
- package/package.json +2 -1
package/dist/src/core/skills.js
CHANGED
|
@@ -25,6 +25,7 @@ export async function discoverSkillDirectories(root, options = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
if (entries.includes("SKILL.md")) {
|
|
27
27
|
const skillPath = join(directory, "SKILL.md");
|
|
28
|
+
const targetName = directory.split(sep).at(-1) ?? "skill";
|
|
28
29
|
let name;
|
|
29
30
|
try {
|
|
30
31
|
const skillStat = await lstat(skillPath);
|
|
@@ -36,14 +37,26 @@ export async function discoverSkillDirectories(root, options = {}) {
|
|
|
36
37
|
catch {
|
|
37
38
|
// Selected invalid skills are rejected by validateSkillDirectory below.
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
const discovered = {
|
|
41
|
+
path: directory,
|
|
42
|
+
...(name ? { name } : {}),
|
|
43
|
+
targetName,
|
|
44
|
+
};
|
|
45
|
+
if (options.include && !options.include(discovered))
|
|
45
46
|
return;
|
|
46
|
-
|
|
47
|
+
try {
|
|
48
|
+
if (options.validate !== false)
|
|
49
|
+
await validateSkillDirectory(directory);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!options.continueOnRejected)
|
|
53
|
+
throw error;
|
|
54
|
+
options.onRejected?.({
|
|
55
|
+
...discovered,
|
|
56
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
47
60
|
result.push(directory);
|
|
48
61
|
// A SKILL.md directory is one atomic skill package. Resources beneath it
|
|
49
62
|
// are validated as content, not recursively treated as additional skills.
|
package/dist/src/core/state.js
CHANGED
|
@@ -138,7 +138,13 @@ async function createInstallRecord(plan, snapshotId, metadata = {}) {
|
|
|
138
138
|
const files = (await Promise.all([...new Set(plan.files.map((file) => file.target))].map(hashDirectory))).flat();
|
|
139
139
|
return {
|
|
140
140
|
packageId: plan.packageId,
|
|
141
|
-
...metadata,
|
|
141
|
+
...(metadata.repository ? { repository: metadata.repository } : {}),
|
|
142
|
+
...(metadata.resolvedCommit
|
|
143
|
+
? { resolvedCommit: metadata.resolvedCommit }
|
|
144
|
+
: {}),
|
|
145
|
+
...(metadata.staticAssessment
|
|
146
|
+
? { staticAssessment: metadata.staticAssessment }
|
|
147
|
+
: {}),
|
|
142
148
|
targetAgents: [...plan.targetAgents],
|
|
143
149
|
files,
|
|
144
150
|
snapshotId,
|
|
@@ -164,6 +170,11 @@ export async function recordInstallBatch(entries, snapshotId) {
|
|
|
164
170
|
*/
|
|
165
171
|
export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
166
172
|
const now = new Date().toISOString();
|
|
173
|
+
const state = await readInstallState();
|
|
174
|
+
const existingActivations = new Map((state.activations ?? []).map((record) => [
|
|
175
|
+
`${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`,
|
|
176
|
+
record,
|
|
177
|
+
]));
|
|
167
178
|
const activationRecords = [];
|
|
168
179
|
const records = [];
|
|
169
180
|
for (const entry of entries) {
|
|
@@ -202,6 +213,9 @@ export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
|
202
213
|
sha256: file.sha256,
|
|
203
214
|
});
|
|
204
215
|
}
|
|
216
|
+
const existing = existingActivations.get(`${entry.plan.packageId}\0${agent}\0${unitId}`);
|
|
217
|
+
const preserveActive = existing?.installationState === "installed" &&
|
|
218
|
+
existing.activationState === "active";
|
|
205
219
|
activationRecords.push({
|
|
206
220
|
packageId: entry.plan.packageId,
|
|
207
221
|
unitId,
|
|
@@ -209,9 +223,9 @@ export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
|
209
223
|
cacheState: "downloaded",
|
|
210
224
|
reviewState: entry.metadata?.reviewed ? "reviewed" : "unreviewed",
|
|
211
225
|
installationState: "installed",
|
|
212
|
-
activationState: "disabled",
|
|
226
|
+
activationState: preserveActive ? "active" : "disabled",
|
|
213
227
|
libraryPath,
|
|
214
|
-
targets: [target],
|
|
228
|
+
targets: preserveActive ? existing.targets : [target],
|
|
215
229
|
libraryFiles: libraryFiles.sort((left, right) => left.path.localeCompare(right.path)),
|
|
216
230
|
updatedAt: now,
|
|
217
231
|
snapshotId,
|
|
@@ -230,9 +244,11 @@ export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
|
230
244
|
files: installFiles.sort((left, right) => left.path.localeCompare(right.path)),
|
|
231
245
|
snapshotId,
|
|
232
246
|
installedAt: now,
|
|
247
|
+
...(entry.metadata?.staticAssessment
|
|
248
|
+
? { staticAssessment: entry.metadata.staticAssessment }
|
|
249
|
+
: {}),
|
|
233
250
|
});
|
|
234
251
|
}
|
|
235
|
-
const state = await readInstallState();
|
|
236
252
|
const ids = new Set(records.map((record) => record.packageId));
|
|
237
253
|
state.installs = [
|
|
238
254
|
...state.installs.filter((record) => !ids.has(record.packageId)),
|
package/dist/src/core/update.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { fetchRepositorySnapshot } from "./source.js";
|
|
4
4
|
import { repositoryCachePath } from "./source.js";
|
|
5
5
|
import { diffRepositorySnapshots } from "./diff.js";
|
|
@@ -7,7 +7,83 @@ import { hashDirectory, readInstallState } from "./state.js";
|
|
|
7
7
|
import { analyzeUpdateSafety } from "./safety.js";
|
|
8
8
|
import { detectAgents, loadoutHome } from "./paths.js";
|
|
9
9
|
import { applySkillInstall, buildSkillPlan, installedAgents, } from "./install.js";
|
|
10
|
-
import { validateSkillDirectory } from "./skills.js";
|
|
10
|
+
import { discoverSkillDirectories, validateSkillDirectory, } from "./skills.js";
|
|
11
|
+
function managedUnitIds(state, packageId) {
|
|
12
|
+
return [
|
|
13
|
+
...new Set((state.activations ?? [])
|
|
14
|
+
.filter((activation) => activation.packageId === packageId &&
|
|
15
|
+
activation.installationState === "installed")
|
|
16
|
+
.map((activation) => activation.unitId ??
|
|
17
|
+
basename(activation.targets[0]?.activePath ?? packageId))
|
|
18
|
+
.filter(Boolean)),
|
|
19
|
+
].sort();
|
|
20
|
+
}
|
|
21
|
+
async function locateManagedSkills(root, unitIds) {
|
|
22
|
+
const normalized = new Map(unitIds.map((unitId) => [unitId.toLowerCase(), unitId]));
|
|
23
|
+
const located = new Map();
|
|
24
|
+
await discoverSkillDirectories(root, {
|
|
25
|
+
validate: false,
|
|
26
|
+
include: (skill) => {
|
|
27
|
+
const unitId = (skill.name ? normalized.get(skill.name.toLowerCase()) : undefined) ??
|
|
28
|
+
normalized.get(skill.targetName.toLowerCase());
|
|
29
|
+
if (!unitId)
|
|
30
|
+
return false;
|
|
31
|
+
located.set(unitId, skill.path);
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return located;
|
|
36
|
+
}
|
|
37
|
+
async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
|
|
38
|
+
if (!unitIds.length) {
|
|
39
|
+
const [diff, safety] = await Promise.all([
|
|
40
|
+
diffRepositorySnapshots(oldRoot, newRoot),
|
|
41
|
+
analyzeUpdateSafety(oldRoot, newRoot),
|
|
42
|
+
]);
|
|
43
|
+
return {
|
|
44
|
+
diff,
|
|
45
|
+
safetyFindings: safety.findings,
|
|
46
|
+
approvalRequired: safety.approvalRequired,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const [oldSkills, newSkills] = await Promise.all([
|
|
50
|
+
locateManagedSkills(oldRoot, unitIds),
|
|
51
|
+
locateManagedSkills(newRoot, unitIds),
|
|
52
|
+
]);
|
|
53
|
+
const diff = [];
|
|
54
|
+
const findings = [];
|
|
55
|
+
for (const unitId of unitIds) {
|
|
56
|
+
const oldPath = oldSkills.get(unitId);
|
|
57
|
+
const newPath = newSkills.get(unitId);
|
|
58
|
+
if (!oldPath || !newPath) {
|
|
59
|
+
findings.push({
|
|
60
|
+
severity: "blocking",
|
|
61
|
+
category: "instruction",
|
|
62
|
+
message: `Managed skill '${unitId}' is missing from one update revision.`,
|
|
63
|
+
paths: [unitId],
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
diff.push(...(await diffRepositorySnapshots(oldPath, newPath)).map((item) => ({
|
|
68
|
+
...item,
|
|
69
|
+
path: `${unitId}/${item.path}`,
|
|
70
|
+
})));
|
|
71
|
+
const safety = await analyzeUpdateSafety(oldPath, newPath);
|
|
72
|
+
findings.push(...safety.findings.map((finding) => ({
|
|
73
|
+
...finding,
|
|
74
|
+
paths: finding.paths.map((path) => `${unitId}/${path}`),
|
|
75
|
+
})));
|
|
76
|
+
}
|
|
77
|
+
const unique = new Map();
|
|
78
|
+
for (const finding of findings)
|
|
79
|
+
unique.set(`${finding.severity}:${finding.category}:${finding.message}:${finding.paths.join(",")}:${finding.names?.join(",") ?? ""}`, finding);
|
|
80
|
+
const safetyFindings = [...unique.values()];
|
|
81
|
+
return {
|
|
82
|
+
diff,
|
|
83
|
+
safetyFindings,
|
|
84
|
+
approvalRequired: safetyFindings.some((finding) => finding.severity === "blocking"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
11
87
|
/** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
|
|
12
88
|
export async function buildUpdatePlan(resolver = async (repository) => fetchRepositorySnapshot(repository)) {
|
|
13
89
|
const state = await readInstallState();
|
|
@@ -39,10 +115,10 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
|
|
|
39
115
|
let approvalRequired = false;
|
|
40
116
|
if (!same && current.path) {
|
|
41
117
|
const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
safetyFindings =
|
|
45
|
-
approvalRequired =
|
|
118
|
+
const analysis = await analyzeManagedUpdate(oldPath, current.path, managedUnitIds(state, record.packageId));
|
|
119
|
+
diff = analysis.diff;
|
|
120
|
+
safetyFindings = analysis.safetyFindings;
|
|
121
|
+
approvalRequired = analysis.approvalRequired;
|
|
46
122
|
}
|
|
47
123
|
return {
|
|
48
124
|
...base,
|
|
@@ -148,18 +224,32 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
|
|
|
148
224
|
if (current.commit.toLowerCase() === record.resolvedCommit.toLowerCase())
|
|
149
225
|
throw new Error(`Package '${packageId}' is already up to date`);
|
|
150
226
|
const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
|
|
151
|
-
const
|
|
227
|
+
const units = managedUnitIds(state, packageId);
|
|
228
|
+
const safety = await analyzeManagedUpdate(oldPath, current.path, units);
|
|
152
229
|
if (safety.approvalRequired && !options.approveRisk) {
|
|
153
230
|
const quarantinePath = options.quarantineOnBlock === false
|
|
154
231
|
? undefined
|
|
155
|
-
: await quarantineUpdate(packageId, current.repository, current.commit, safety.
|
|
156
|
-
throw new Error(`Update is blocked pending explicit risk approval${quarantinePath ? `; quarantined at ${quarantinePath}` : ""}: ${safety.
|
|
232
|
+
: await quarantineUpdate(packageId, current.repository, current.commit, safety.safetyFindings);
|
|
233
|
+
throw new Error(`Update is blocked pending explicit risk approval${quarantinePath ? `; quarantined at ${quarantinePath}` : ""}: ${safety.safetyFindings
|
|
157
234
|
.filter((finding) => finding.severity === "blocking")
|
|
158
235
|
.map((finding) => finding.message)
|
|
159
236
|
.join(" ")}`);
|
|
160
237
|
}
|
|
161
238
|
const agents = installedAgents(await (runtime.detectAgents ?? detectAgents)(), record.targetAgents);
|
|
162
|
-
const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents
|
|
239
|
+
const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
|
|
240
|
+
? {
|
|
241
|
+
include: (skill) => units.some((unitId) => unitId.toLowerCase() === skill.name?.toLowerCase() ||
|
|
242
|
+
unitId.toLowerCase() === skill.targetName.toLowerCase()),
|
|
243
|
+
}
|
|
244
|
+
: {});
|
|
245
|
+
if (units.length) {
|
|
246
|
+
const plannedUnits = [
|
|
247
|
+
...new Set(plan.files.map((file) => basename(file.target))),
|
|
248
|
+
].sort();
|
|
249
|
+
if (plannedUnits.length !== units.length ||
|
|
250
|
+
plannedUnits.some((unitId, index) => unitId.toLowerCase() !== units[index].toLowerCase()))
|
|
251
|
+
throw new Error(`Update plan changed the managed skill set for '${packageId}'; expected ${units.join(", ")}, received ${plannedUnits.join(", ") || "none"}.`);
|
|
252
|
+
}
|
|
163
253
|
const verifier = runtime.verify ?? verifyInstalledSkills;
|
|
164
254
|
let verificationFailure;
|
|
165
255
|
let verificationSnapshotId;
|
|
@@ -167,6 +257,17 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
|
|
|
167
257
|
const snapshotId = await applySkillInstall(plan, {
|
|
168
258
|
repository: current.repository,
|
|
169
259
|
resolvedCommit: current.commit,
|
|
260
|
+
reviewed: true,
|
|
261
|
+
staticAssessment: {
|
|
262
|
+
status: safety.approvalRequired
|
|
263
|
+
? "blocking"
|
|
264
|
+
: safety.safetyFindings.length
|
|
265
|
+
? "warning"
|
|
266
|
+
: "clear",
|
|
267
|
+
findingCount: safety.safetyFindings.length,
|
|
268
|
+
assessedAt: new Date().toISOString(),
|
|
269
|
+
policy: "install-safety-v1",
|
|
270
|
+
},
|
|
170
271
|
}, {
|
|
171
272
|
allowManagedReplacement: true,
|
|
172
273
|
replaceManagedTargets: true,
|
package/dist/src/core/upgrade.js
CHANGED
|
@@ -33,6 +33,7 @@ export async function planUpgrade(selection, options = {}) {
|
|
|
33
33
|
? { fetchSnapshot: options.fetchSnapshot }
|
|
34
34
|
: {}),
|
|
35
35
|
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
36
|
+
...(options.access ? { access: options.access } : {}),
|
|
36
37
|
}),
|
|
37
38
|
outcomes(),
|
|
38
39
|
]);
|
|
@@ -257,6 +257,14 @@ export const installRecordSchema = z
|
|
|
257
257
|
files: z.array(fileHashSchema),
|
|
258
258
|
snapshotId: text,
|
|
259
259
|
installedAt: text,
|
|
260
|
+
staticAssessment: z
|
|
261
|
+
.object({
|
|
262
|
+
status: z.enum(["clear", "warning", "blocking"]),
|
|
263
|
+
findingCount: z.number().int().nonnegative(),
|
|
264
|
+
assessedAt: z.iso.datetime(),
|
|
265
|
+
policy: text,
|
|
266
|
+
})
|
|
267
|
+
.optional(),
|
|
260
268
|
})
|
|
261
269
|
.passthrough();
|
|
262
270
|
export const mcpInstallRecordSchema = z
|