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.
@@ -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
- if (options.include &&
40
- !options.include({
41
- path: directory,
42
- ...(name ? { name } : {}),
43
- targetName: directory.split(sep).at(-1) ?? "skill",
44
- }))
40
+ const discovered = {
41
+ path: directory,
42
+ ...(name ? { name } : {}),
43
+ targetName,
44
+ };
45
+ if (options.include && !options.include(discovered))
45
46
  return;
46
- await validateSkillDirectory(directory);
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.
@@ -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)),
@@ -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
- diff = await diffRepositorySnapshots(oldPath, current.path);
43
- const safety = await analyzeUpdateSafety(oldPath, current.path);
44
- safetyFindings = safety.findings;
45
- approvalRequired = safety.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 safety = await analyzeUpdateSafety(oldPath, current.path);
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.findings);
156
- throw new Error(`Update is blocked pending explicit risk approval${quarantinePath ? `; quarantined at ${quarantinePath}` : ""}: ${safety.findings
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,
@@ -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